Compare commits

..

91 Commits

Author SHA1 Message Date
clawbot
bc4e78c466 fix: consistent token contract display on confirm-tx page
All checks were successful
check / check (push) Successful in 23s
The confirm-tx page displayed token contract addresses as plain text,
while addressToken used a styled container (bg-hover rounded-md) with
color dot, copy-on-click, etherscan link, and token metadata.

Changes:
- confirm-tx HTML: use bg-hover rounded-md container matching addressToken
- confirm-tx JS: render contract with addressDotHtml, copy-on-click span,
  etherscan link, and optional token name/symbol metadata
- send.js: pass tokenName to confirmTx for richer contract display
- Add click-to-copy handler for contract address on confirm-tx

Closes #70
2026-02-28 12:06:25 -08:00
e8ede7010a Merge pull request 'fix: use formatAddressHtml in receive view for display consistency' (#69) from fix/issue-58-receive-address-consistency into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #69
2026-02-28 20:57:08 +01:00
a2fbb0e30d fix: use formatAddressHtml in receive view for display consistency
All checks were successful
check / check (push) Successful in 22s
The receive view was using raw textContent and a manually constructed
color dot instead of the shared formatAddressHtml helper used by other
views. This violated the display consistency policy ('Same data
formatted identically across all screens').

Changes:
- Use formatAddressHtml() to render address with color dot, title
  (e.g. 'Wallet 1 — Address 1'), and ENS name — matching addressDetail
- Make the address block itself click-to-copy (matching policy:
  'Clicking any address copies the full untruncated value')
- Replace separate receive-dot/receive-address spans with a single
  receive-address-block element
- Address is still shown in full (no truncation) as appropriate for
  the receive view

Closes #58
2026-02-28 11:47:45 -08:00
24464ffe33 Merge pull request 'fix: resolve token symbols from multiple sources (closes #51)' (#52) from fix/usdc-symbol-display into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #52
2026-02-28 20:40:43 +01:00
34c66d19c4 Merge branch 'main' into fix/usdc-symbol-display
All checks were successful
check / check (push) Successful in 22s
2026-02-28 20:40:10 +01:00
e09904147b Merge pull request 'fix: consistent transaction view title (closes #65)' (#66) from fix/65-transaction-view-title into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #66
2026-02-28 20:39:47 +01:00
user
b02a1d3a55 fix: always use 'Transaction' as detail view heading
All checks were successful
check / check (push) Successful in 22s
The transaction detail view was dynamically changing its title to match
the transaction type (e.g. 'Swap' for contract interactions), causing
inconsistency with the Screen Map specification. The heading is now
always 'Transaction' regardless of type. The action type is still
shown in the 'Action' detail section below.

Closes #65
2026-02-28 11:38:49 -08:00
clawbot
9a7aa1f4fc fix: resolve token symbols from multiple sources (closes #51)
All checks were successful
check / check (push) Successful in 22s
When tokenBalances doesn't contain an entry for a token (e.g. before
balances are fetched), the symbol fell back to '?' in addressToken
and send views.

Add resolveSymbol() helper that checks tokenBalances → TOKEN_BY_ADDRESS
(known tokens) → trackedTokens → truncated address as last resort.

Fixes USDC and other known tokens showing '?' when balance data
hasn't loaded yet.
2026-02-28 11:37:38 -08:00
9788db95f2 Merge pull request 'fix: show decoded swap details on success-tx view (closes #63)' (#64) from fix/63-swap-success-view into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #64
2026-02-28 20:35:39 +01:00
clawbot
9981be6986 fix: show decoded swap details on success-tx view (closes #63)
All checks were successful
check / check (push) Successful in 22s
Carry decoded calldata info (action name, description, token details,
amounts, addresses) from the approval confirmation view through to the
success-tx view. For swap transactions, this now shows the same decoded
details (protocol, action, token symbols, amounts) that appeared on the
signing confirmation screen.

Changes:
- approval.js: store decoded calldata in pendingTxDetails.decoded
- txStatus.js: carry decoded through state.viewData, render in success view
- index.html: add success-tx-decoded container element
2026-02-28 11:32:55 -08:00
bbf5945ff1 Merge pull request 'fix: enforce UI policies on transaction detail view (closes #59)' (#62) from fix/59-transaction-view-ui-policies into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #62
2026-02-28 20:30:43 +01:00
clawbot
607d2349b0 fix: prevent double symbol display on tx broadcast status views
All checks were successful
check / check (push) Successful in 22s
The decodeCalldata function in approval.js was embedding the token symbol
into the Amount value string (e.g. '2.0000 USDT'). This value was then
assigned to pendingTxDetails.amount, and txStatus.js would append the
symbol again, producing '2.0000 USDT ETH' (or '2.0000 USDT USDT' when
the token was in TOKEN_BY_ADDRESS).

Fix: decodeCalldata now provides a rawValue field (numeric only) on
Amount details. pendingTxDetails.amount uses rawValue when available,
so txStatus.js can append the correct symbol exactly once.

Affected paths:
- approve() decoded amount (approve calldata)
- transfer() decoded amount (transfer calldata)
- pendingTxDetails.amount assignment

Audited all other amount+symbol display sites:
- txStatus.js showWait/showSuccess/showError: correctly derive symbol
  from txInfo.token, no duplication
- confirmTx.js show(): builds symbol independently, amount is raw — OK
- send.js: amount is raw user input — OK
- addressToken.js: uses balanceLine helper — OK
- transactions.js parseTx/parseTokenTransfer: separate value/symbol — OK

Fixes #59
2026-02-28 11:27:26 -08:00
clawbot
3c2d553070 test: add V4 swap ERC20→ERC20 decoder regression test
All checks were successful
check / check (push) Successful in 37s
Adds a test that constructs a Uniswap V4 USDT→USDC swap using
SETTLE/SWAP_EXACT_IN_SINGLE/TAKE sub-actions inside a V4_SWAP command.
Without decodeV4Swap(), the output token would be unresolvable and the
swap name would not show 'USDT → USDC'. This test fails on the old code
and passes with the decodeV4Swap() fix.

Refs: #59
2026-02-28 11:22:14 -08:00
user
55346b484b fix: decode V4_SWAP command to resolve ERC20 token symbols
All checks were successful
check / check (push) Successful in 21s
The Uniswap Universal Router calldata decoder listed V4_SWAP (0x10) in
command names but never decoded its inner actions to extract token
addresses. This caused all V4 swaps (e.g. USDT→USDC) to display as
'Swap ETH → ETH' because tokenIn/tokenOut defaulted to null, which
tokenInfo() resolved as native ETH.

Added decodeV4Swap() which parses the V4 inner action bytes:
- SETTLE (0x0b) → extracts input token currency address
- TAKE (0x0e) → extracts output token currency address
- SWAP_EXACT_IN/OUT and their SINGLE variants → extracts tokens from
  pool keys and paths, plus amounts

These addresses are then resolved against the known token list
(TOKEN_BY_ADDRESS) to display correct symbols like USDT, USDC, etc.

Fixes #59
2026-02-28 11:16:10 -08:00
clawbot
93565c7196 fix: match etherscan link styling to other views (remove extra border/hover/padding)
All checks were successful
check / check (push) Successful in 21s
The etherscanLinkHtml helper had added border, px-1, hover:bg-fg,
hover:text-bg, and cursor-pointer classes that no other view uses.
All other views (addressDetail, addressToken, confirmTx, send, receive,
home, txStatus) use only 'inline-flex items-center' on etherscan links.
Removed the extra classes for consistency.
2026-02-28 11:04:37 -08:00
clawbot
71ef08fe85 fix: address all 3 violations from #59
1. Protocol name now has Etherscan link (was appearing clickable but wasn't)
2. Token contract addresses: symbol shown separately, then color dot +
   full address + Etherscan link (symbol no longer interposed)
3. Raw data section moved after transaction hash (no longer pushes
   useful info off screen)
2026-02-28 11:03:20 -08:00
clawbot
8d230aceb6 fix: enforce UI policies on transaction detail view
- Add clickable affordance (border + hover state) to all Etherscan external
  links on addresses and transaction hash per clickable affordance policy
- Fix address display when ENS name is present: show color dot and Etherscan
  link on the full address line (previously only shown on ENS name line)
- Extract etherscanLinkHtml helper for consistent link styling

Closes #59
2026-02-28 11:03:20 -08:00
6031c3e76c Merge pull request 'fix: persist tx decoding across popup close/reopen (closes #60)' (#61) from fix/60-tx-view-decode-persistence into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #61
2026-02-28 19:38:16 +01:00
user
436fe22296 fix: call loadCalldata from render() so tx decoding persists across popup close/reopen
All checks were successful
check / check (push) Successful in 22s
Previously loadCalldata was only called from show(), meaning when the popup
was closed and reopened (triggering render() directly via restoreView), the
calldata decoding section was hidden and never re-fetched. Now render()
triggers loadCalldata for contract calls, so decoded data always appears.

Closes #60
2026-02-28 10:25:34 -08:00
82a7db63b5 Merge pull request 'fix: show own labelled address for swap transactions (closes #55)' (#57) from fix/55-swap-tx-address into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #57
2026-02-28 18:16:12 +01:00
7c53c48cb1 Merge branch 'main' into fix/55-swap-tx-address
All checks were successful
check / check (push) Successful in 22s
2026-02-28 18:04:14 +01:00
4d9a8a49b9 Merge pull request 'fix: fall back to known token list for symbol/name/decimals' (#54) from fix/51-usdc-symbol-fallback into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #54
2026-02-28 18:04:04 +01:00
user
996003fd79 fix: add trackedTokens fallback for symbol/name/decimals resolution
All checks were successful
check / check (push) Successful in 22s
Extends the fallback chain to: tb → state.trackedTokens → TOKEN_BY_ADDRESS → '?'

This ensures user-added custom tokens (not just hardcoded known tokens)
display correct symbol, name, and decimals even when Blockscout hasn't
returned balance data (e.g. zero-balance tracked tokens).
2026-02-28 09:01:29 -08:00
user
2c9a34aff6 fix: show own labelled address for swap transactions in tx lists
All checks were successful
check / check (push) Successful in 22s
For swap transactions in the transaction history list views (home and
addressDetail), display the user's own labelled wallet address instead
of the contract/counterparty address. The contract address is not useful
in the list view — users need to see which of their addresses performed
the swap.

Closes #55
2026-02-28 08:57:16 -08:00
user
173d75c57a fix: fall back to known token list for symbol/name/decimals
All checks were successful
check / check (push) Successful in 22s
When a token's balance entry is missing or incomplete (e.g. not yet
fetched from Blockscout), the address-token view and send view now
fall back to the built-in known token list for symbol, name, and
decimals instead of showing '?'.

Also includes token name in the balance object returned by
fetchTokenBalances so the contract info well can display it.

Fixes #51
2026-02-28 08:44:09 -08:00
fb67359b3f Merge pull request 'fix: add reverse ENS lookups for all displayed addresses (closes #22)' (#25) from fix/reverse-ens-lookups into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #25
2026-02-28 10:06:45 +01:00
1986704569 Merge branch 'main' into fix/reverse-ens-lookups
All checks were successful
check / check (push) Successful in 21s
2026-02-28 10:05:16 +01:00
49c29f6bb3 Merge pull request 'fix: preserve ENS names on lookup failure, add debug logging (closes #22)' (#24) from fix/ens-reverse-lookup into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #24
2026-02-28 10:05:03 +01:00
cdb7f478e2 Merge branch 'main' into fix/ens-reverse-lookup
All checks were successful
check / check (push) Successful in 22s
2026-02-28 10:04:26 +01:00
cbe77d0224 Merge pull request 'fix: show wallet/address titles across all views (closes #26, closes #27, closes #28, closes #29)' (#30) from fix/address-title-consistency into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #30
2026-02-28 10:03:40 +01:00
user
2abb720d54 fix: show wallet/address titles in addressDetail and addressToken tx lists (closes #29)
All checks were successful
check / check (push) Successful in 21s
2026-02-27 14:30:09 -08:00
user
bf9a483031 fix: show wallet/address titles in send, txStatus, and home tx list (closes #26, closes #27, closes #28)
All checks were successful
check / check (push) Successful in 22s
- send.js: show addressTitle() above ENS name and address in From field
- txStatus.js: show addressTitle() in To address when it's a local wallet
- home.js: show addressTitle() for counterparties in tx list when they
  are local wallet addresses
2026-02-27 14:28:20 -08:00
79fec8551f fix: add reverse ENS lookups for all displayed addresses (closes #22)
All checks were successful
check / check (push) Successful in 22s
Previously, ENS reverse lookups were only performed for the single
counterparty address (from or to depending on direction). This meant
contract interaction targets and the non-counterparty side of
transactions never got ENS names resolved.

Now both from and to addresses are collected for ENS resolution,
ensuring all displayed addresses show their ENS names when available.
2026-02-27 14:26:04 -08:00
user
da428a3815 fix: preserve ENS names on lookup failure, add debug logging (closes #22)
All checks were successful
check / check (push) Successful in 22s
Two issues that could cause ENS names to disappear:

1. refreshBalances: on ENS lookup error, addr.ensName was set to null,
   wiping any previously resolved name. Now keeps the existing value
   on error — only overwrites on successful lookup.

2. ens.js cache: failed lookups were cached as null for 12 hours,
   preventing retries even after transient errors resolved. Now skips
   caching on failure so subsequent lookups retry immediately.

Added debug logging to ENS reverse lookups in refreshBalances.
2026-02-27 14:24:32 -08:00
171b21c5d8 Merge pull request 'fix: show wallet name for own addresses on approve-tx view (closes #21)' (#23) from fix/approval-address-title into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #23
2026-02-27 23:20:26 +01:00
e7a960c601 Merge branch 'main' into fix/approval-address-title
All checks were successful
check / check (push) Successful in 22s
2026-02-27 23:20:14 +01:00
b69eec40ef Merge pull request 'fix: low-severity security findings L3, L4, L5 (closes #6)' (#8) from fix/low-severity-security into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #8
2026-02-27 23:19:08 +01:00
cacf2c683c Merge branch 'main' into fix/low-severity-security
All checks were successful
check / check (push) Successful in 22s
2026-02-27 23:18:53 +01:00
user
15e856e63f fix: show wallet name for own addresses on approve-tx view (closes #21)
All checks were successful
check / check (push) Successful in 21s
The approve-tx view was showing raw addresses for From/To even when they
belonged to the user's wallet. Now uses addressTitle() to display the
wallet name (e.g. 'My Wallet — Address 1') consistently with other views.
2026-02-27 14:18:29 -08:00
43e10521ef Merge pull request 'fix: add fallback popup window for tx/sign approval requests (closes #4)' (#17) from fix/tx-approval-popup into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #17
2026-02-27 23:15:02 +01:00
04ba926d43 Merge branch 'main' into fix/tx-approval-popup
All checks were successful
check / check (push) Successful in 22s
2026-02-27 23:10:58 +01:00
4fdbc5adae fmt: prettier format content/index.js
All checks were successful
check / check (push) Successful in 21s
2026-02-27 14:10:37 -08:00
85427e1fd4 Merge branch 'main' into fix/low-severity-security
Some checks failed
check / check (push) Failing after 13s
2026-02-27 23:08:40 +01:00
8226495994 Merge pull request 'fix: display swaps and contract calls correctly in tx history (closes #3)' (#10) from fix/swap-display into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #10
2026-02-27 23:08:16 +01:00
2f57370155 Merge branch 'main' into fix/swap-display
All checks were successful
check / check (push) Successful in 22s
2026-02-27 23:07:31 +01:00
c6d5cf4e64 Merge pull request 'feat: add wallet deletion from settings (closes #13)' (#14) from feat/delete-wallet into main
All checks were successful
check / check (push) Successful in 22s
Reviewed-on: #14
2026-02-27 23:04:45 +01:00
34e29d7659 fix: re-render wallet list after deletion by calling showSettingsView
All checks were successful
check / check (push) Successful in 21s
2026-02-27 14:02:44 -08:00
6d0531f1b4 Merge pull request 'fix: use grey well for contract address in address-token view (closes #9)' (#15) from fix/address-token-grey-well into main
All checks were successful
check / check (push) Successful in 21s
Reviewed-on: #15
2026-02-27 23:00:00 +01:00
8893f5dce7 refactor: delete-wallet-confirm as standalone full view
All checks were successful
check / check (push) Successful in 22s
Replace the inline confirmation div at the bottom of Settings with a
proper full-screen view (view-delete-wallet-confirm). This fixes the
issue where the confirmation was offscreen on the 360x600 popup.

- New view with back button, title, warning text, password input,
  and red-text Confirm Delete button
- Dedicated flash area for password errors
- New deleteWallet.js module with init/show pattern
- Added delete-wallet-confirm to VIEWS array in helpers.js
- Removed old inline confirmation HTML and logic from settings
2026-02-27 13:58:58 -08:00
2bffa91045 fix: reduce contract info well margins to prevent address wrapping
All checks were successful
check / check (push) Successful in 22s
2026-02-27 13:54:19 -08:00
2b0b889b01 fix: use wallet.encryptedSecret not wallet.encrypted for password verify
All checks were successful
check / check (push) Successful in 22s
2026-02-27 13:52:08 -08:00
5936199676 fix: place color dot next to address, not title, matching convention
All checks were successful
check / check (push) Successful in 22s
2026-02-27 13:03:43 -08:00
user
8824237db6 fix: match approval view display consistency for decoded calldata
All checks were successful
check / check (push) Successful in 21s
- Restructured calldata section to use same well layout as approval view:
  Action label + bold name + structured details
- Always show raw data section below decoded well
- Unknown contract calls show method name in well instead of inline
2026-02-27 13:01:53 -08:00
user
aaeb38d7c6 fix: show Swap type label and heading on transaction detail page
All checks were successful
check / check (push) Successful in 21s
2026-02-27 13:00:07 -08:00
f2e44ff4ab fix: use windows.create() for tx/sign approval popups instead of openPopup()
All checks were successful
check / check (push) Successful in 22s
action.openPopup() is unreliable when called from the background script
during an async message handler — it requires a user gesture context.
tx and sign approvals are triggered programmatically by dApp RPC calls,
not by user clicking the toolbar icon, so openPopup() fails silently.

Use windows.create() directly for tx/sign approvals, matching the
standard extension pattern (used by MetaMask and others). Site-connection
approvals retain openPopup() since they can fall back to the user
clicking the toolbar icon.

Also updates popup window dimensions to 360x600 to match the standard
popup viewport specified in README.

Closes #4
2026-02-27 12:57:55 -08:00
107c243f65 fix: use consistent [x] delete buttons, add inline rename
All checks were successful
check / check (push) Successful in 8s
- Delete buttons now use [x] with border, matching token and site
  removal patterns in settings
- Wallet names are click-to-rename (inline input), matching the
  home view rename UX
2026-02-27 12:53:46 -08:00
655b90c7df feat: add wallet deletion from settings (closes #13)
- Per-wallet [delete] links in settings wallet list
- Monochrome styling throughout, no red/danger colors
- Password confirmation modal with warning text
- Cleans up site permissions for deleted addresses
- Switches to first remaining wallet or shows welcome if none left
2026-02-27 12:53:46 -08:00
34cd72be88 fix: rework wallet deletion per review feedback
- Remove all red/danger styling, use standard monochrome colors
- Add wallet picker dropdown instead of relying on selectedWallet
- Fix encryptedSecret field name (was wallet.encrypted)
- Populate dropdown when settings view opens
- Confirmation modal uses standard border styling
2026-02-27 12:53:46 -08:00
user
689bcbf171 feat: add wallet deletion from settings (closes #13) 2026-02-27 12:53:46 -08:00
4eefe4c1af Merge pull request 'fix: pass UUID approval ID as string, not parseInt (closes #4)' (#18) from fix/approval-popup-uuid into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #18
2026-02-27 21:50:27 +01:00
3b419c7517 fix: add missing TOKEN_BY_ADDRESS import in addressToken view
All checks were successful
check / check (push) Successful in 22s
2026-02-27 12:50:26 -08:00
8b7d73cc35 fix: pass UUID approval ID as string, not parseInt (closes #4)
All checks were successful
check / check (push) Successful in 22s
The approval ID was changed from sequential integers to crypto.randomUUID()
strings for security, but the popup still called parseInt() on it, which
converted the UUID to NaN. This caused every approval lookup to fail,
preventing the confirmation popup from displaying pending tx/sign requests.
2026-02-27 12:34:23 -08:00
3fd3e30f44 fix: label swap methods as "Swap" in tx lists, remove unused variable
All checks were successful
check / check (push) Successful in 23s
- Map known DEX methods (execute, swap, multicall, etc.) to "Swap"
  label instead of raw method name like "Execute"
- Remove unused displayData variable in transactionDetail.js

Addresses review feedback on PR #10.
2026-02-27 12:31:25 -08:00
clawbot
76059c3674 fix: display swaps and contract calls correctly in tx history (closes #3)
- Preserve contract call metadata (direction, label, method) when token
  transfers merge with normal txs in fetchRecentTransactions
- Handle 'contract' direction in counterparty display for home and
  address detail list views
- Add decoded calldata display to transaction detail view, fetching
  raw input from Blockscout and using decodeCalldata from approval.js
- Show 'Unknown contract call' with raw hex for unrecognized calldata
- Export decodeCalldata from approval.js for reuse
2026-02-27 12:31:13 -08:00
8332570758 fix: increase well horizontal margin to mx-4 per review
All checks were successful
check / check (push) Successful in 22s
2026-02-27 12:27:23 -08:00
7b004ddda4 fix: rework contract info well per review feedback
All checks were successful
check / check (push) Successful in 22s
- Remove border, add rounded corners and horizontal margin
- Each attribute on its own line (key: value format)
- Move well below send/receive buttons
- Add project/token URL from tokenlist when available
- Import TOKEN_BY_ADDRESS for URL lookup
2026-02-27 12:26:24 -08:00
91eefa1667 fix: use grey well for contract address display in address-token view
All checks were successful
check / check (push) Successful in 22s
- Replace border-b styling with bg-hover + dashed border for visual
  distinction from wallet address
- Rename label from "Token Contract" to "Contract Address"
- Addresses feedback on #9
2026-02-27 12:15:35 -08:00
0ed7b8e61d Merge pull request 'fix: show ERC-20 contract details in address-token view (closes #9)' (#11) from fix/address-token-details into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #11
2026-02-27 21:09:37 +01:00
user
560065dd77 fix: show ERC-20 contract details in address-token view (closes #9)
All checks were successful
check / check (push) Successful in 22s
2026-02-27 12:06:22 -08:00
user
27f16191b4 fix(L4): use location.origin for postMessage, one-shot UUID listener
Some checks failed
check / check (push) Failing after 13s
- Content script sends UUID via location.origin instead of "*"
- Inpage UUID listener removes itself after first message to prevent
  malicious pages from overriding the persisted UUID
2026-02-27 11:58:57 -08:00
909543e943 fix(L5): truncate token name/symbol from RPC responses
Limits token name to 64 chars and symbol to 12 chars to prevent
storage of excessively long values from malicious contracts.
2026-02-27 11:58:19 -08:00
04a34d1a5e fix(L4): generate EIP-6963 provider UUID at install time
UUID is generated once via crypto.randomUUID(), persisted in
chrome.storage.local, and sent from the content script to the
inpage script via postMessage.
2026-02-27 11:58:19 -08:00
98f68adb11 fix(L3): isUnlocked() returns false when no accounts exposed
_metamask.isUnlocked() now checks provider.selectedAddress instead of
always returning true.
2026-02-27 11:58:19 -08:00
0413c52229 Merge pull request 'security: fix high-severity findings from audit (closes #6)' (#7) from fix/high-severity-security into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #7
2026-02-27 20:56:43 +01:00
b01df0639b Merge branch 'main' into fix/high-severity-security
All checks were successful
check / check (push) Successful in 21s
2026-02-27 20:56:09 +01:00
8beb3cd70c Merge pull request 'Fix all RULES.md divergences' (#2) from fix/rules-audit-divergences into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #2
2026-02-27 20:55:25 +01:00
31b22c1325 style: format README.md and RULES.md with Prettier
All checks were successful
check / check (push) Successful in 21s
2026-02-27 11:39:44 -08:00
eec96f9054 security: clear decrypted secrets after use (best-effort)
All checks were successful
check / check (push) Successful in 21s
2026-02-27 11:36:56 -08:00
f13cd0fd47 security: add TODO comments for password plaintext over runtime.sendMessage 2026-02-27 11:36:19 -08:00
b478d9efa9 security: validate sender URL for popup-only messages 2026-02-27 11:35:42 -08:00
d59ebfd461 security: derive RPC origin from sender instead of trusting msg.origin 2026-02-27 11:35:31 -08:00
13e2bdb0b0 security: add prominent danger warning for eth_sign requests 2026-02-27 11:35:21 -08:00
95314ff229 security: replace predictable sequential approval IDs with crypto.randomUUID() 2026-02-27 11:34:48 -08:00
1237cf8491 security: increase minimum password length from 8 to 12 characters 2026-02-27 11:34:32 -08:00
afc4868001 docs: document Blockscout as third external service in README
Some checks failed
check / check (push) Failing after 13s
2026-02-27 03:25:02 -08:00
a6017ce32c docs: add agent-protection notice to RULES.md 2026-02-27 03:25:01 -08:00
9cceca8576 Merge branch 'main' into fix/rules-audit-divergences
All checks were successful
check / check (push) Successful in 22s
2026-02-27 11:55:51 +01:00
6a3be80379 Merge pull request 'add tracked token list management to settings' (#5) from feature/settings-token-management into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #5
2026-02-27 11:55:32 +01:00
3d8feb4c5a Add token management to Settings
All checks were successful
check / check (push) Successful in 22s
- Tracked Tokens well in settings with [x] remove buttons
- New settings-addtoken view with:
  - Top-10 quick-pick buttons (tracked ones dimmed+disabled)
  - Top-100 dropdown showing "Token Name (SYMBOL)", tracked disabled
  - Manual contract address entry with RPC lookup
- View comment in helpers.js about keeping README in sync
2026-02-27 17:52:30 +07:00
aca8c4b2a7 Add name and url fields to all 512 tokens in tokenList.js
Names fetched from CoinGecko bulk API (100% coverage).
URLs sourced from ethereum-lists GitHub repo + manual curation
for major tokens. Some lesser-known tokens have empty URLs which
can be populated incrementally.
2026-02-27 17:51:08 +07:00
user
2244b52f5f fix: rules audit items 1,3,6 (closes #1)
All checks were successful
check / check (push) Successful in 22s
2026-02-27 02:18:45 -08:00
33 changed files with 2926 additions and 550 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ dist/
# Yarn # Yarn
.yarn-integrity .yarn-integrity
package-lock.json

View File

@@ -15,9 +15,10 @@ Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
Everything you need, nothing you don't. We import as few libraries as possible, Everything you need, nothing you don't. We import as few libraries as possible,
don't implement any crypto, and don't send user-specific data anywhere but a don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The (user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts precisely two external services: the configured RPC node for extension contacts exactly three external services: the configured RPC node for
blockchain interactions, and a public CoinDesk API (no API key) to get realtime blockchain interactions, a public CoinDesk API (no API key) for realtime price
price information. information, and a Blockscout block-explorer API for transaction history and
token balances. All three endpoints are user-configurable.
In the extension is a hardcoded list of the top ERC20 contract addresses. You In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -534,7 +535,7 @@ transitions.
### External Services ### External Services
AutistMask is not a fully self-contained offline tool. It necessarily AutistMask is not a fully self-contained offline tool. It necessarily
communicates with two external services to function as a wallet: communicates with three external services to function as a wallet:
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query - **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
@@ -543,11 +544,24 @@ communicates with two external services to function as a wallet:
receipts. The default endpoint is a public RPC (configurable by the user to receipts. The default endpoint is a public RPC (configurable by the user to
any endpoint they prefer, including a local node). By default the extension any endpoint they prefer, including a local node). By default the extension
talks to `https://ethereum-rpc.publicnode.com`. talks to `https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for - **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
displaying fiat values. The price is cached for 5 minutes to avoid excessive displaying fiat values. The price is cached for 5 minutes to avoid excessive
requests. No API key required. No user data is sent — only a list of token requests. No API key required. No user data is sent — only a list of token
symbols. Note that CoinDesk will receive your client IP. symbols. Note that CoinDesk will receive your client IP.
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
- **Blockscout block-explorer API**: Used to fetch transaction history (normal
transactions and ERC-20 token transfers), ERC-20 token balances, and token
holder counts (for spam filtering). The default endpoint is
`https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
- **Data sent**: Ethereum addresses. Blockscout receives the user's
addresses to query their transaction history and token balances. No
private keys, passwords, or signing operations are sent.
What the extension does NOT do: What the extension does NOT do:
@@ -557,9 +571,10 @@ What the extension does NOT do:
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
The user's RPC endpoint and the CoinDesk price API are the only external These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
services. Users who want maximum privacy can point the RPC at their own node the only external services. All three endpoints are user-configurable. Users who
(price fetching can be disabled in a future version). want maximum privacy can point the RPC and Blockscout URLs at their own
self-hosted instances (price fetching can be disabled in a future version).
### Dependencies ### Dependencies

View File

@@ -1,3 +1,8 @@
> **⚠️ THIS FILE MUST NEVER BE MODIFIED BY AGENTS.** RULES.md is maintained
> exclusively by the project owner. AI agents, bots, and automated tools must
> treat this file as read-only. If an audit finds a divergence between the code
> and this file, the code must be changed to match — never the other way around.
# AutistMask Rules Checklist # AutistMask Rules Checklist
This file is derived from README.md and REPO_POLICIES.md for use as an audit This file is derived from README.md and REPO_POLICIES.md for use as an audit
@@ -18,7 +23,7 @@ contradicts either, the originals govern.
## External Communication ## External Communication
- [ ] Extension contacts exactly three external services: configured RPC - [ ] Extension contacts exactly three external services: configured RPC
endpoint, CoinDesk price API, and configured Blockscout API endpoint, CoinDesk price API, and Blockscout block-explorer API
- [ ] No analytics, telemetry, or tracking - [ ] No analytics, telemetry, or tracking
- [ ] No user-specific data sent except to the configured RPC endpoint - [ ] No user-specific data sent except to the configured RPC endpoint
- [ ] No Infura/Alchemy hard dependency - [ ] No Infura/Alchemy hard dependency

View File

@@ -30,7 +30,6 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
let nextApprovalId = 1;
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
@@ -94,11 +93,13 @@ function resetPopupUrl() {
} }
} }
// Fallback: open approval in a separate window (used when openPopup is unavailable) // Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
function openApprovalWindow(id) { function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 400; const popupWidth = 360;
const popupHeight = 500; const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => { windowsApi.getLastFocused((currentWin) => {
const opts = { const opts = {
@@ -127,7 +128,7 @@ function openApprovalWindow(id) {
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch). // Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) { function requestApproval(origin, hostname) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = nextApprovalId++; const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve }; pendingApprovals[id] = { origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") { if (actionApi && typeof actionApi.openPopup === "function") {
@@ -149,10 +150,12 @@ function requestApproval(origin, hostname) {
} }
// Open a tx-approval popup and return a promise that resolves with txHash or error. // Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses the toolbar popup only — no fallback window. // Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestTxApproval(origin, hostname, txParams) { function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = nextApprovalId++; const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
@@ -161,30 +164,17 @@ function requestTxApproval(origin, hostname, txParams) {
type: "tx", type: "tx",
}; };
if (actionApi && typeof actionApi.setPopup === "function") { openApprovalWindow(id);
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
}
if (actionApi && typeof actionApi.openPopup === "function") {
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => {});
}
} catch {
// openPopup unsupported — user clicks toolbar icon
}
}
}); });
} }
// Open a sign-approval popup and return a promise that resolves with { signature } or { error }. // Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
// Uses the toolbar popup only — no fallback window. If openPopup() fails the // Uses windows.create() directly because sign approvals are triggered programmatically
// popup URL is still set, so the user can click the toolbar icon to respond. // (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestSignApproval(origin, hostname, signParams) { function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = nextApprovalId++; const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
@@ -193,30 +183,17 @@ function requestSignApproval(origin, hostname, signParams) {
type: "sign", type: "sign",
}; };
if (actionApi && typeof actionApi.setPopup === "function") { openApprovalWindow(id);
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
}
if (actionApi && typeof actionApi.openPopup === "function") {
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => {});
}
} catch {
// openPopup unsupported — user clicks toolbar icon
}
}
}); });
} }
// Detect when an approval popup (browser-action) closes without a response. // Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals are NOT auto-rejected on disconnect because toolbar // TX and sign approvals now use windows.create() and are handled by the
// popups naturally close on focus loss and the user can reopen them. // windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
const id = parseInt(port.name.split(":")[1], 10); const id = port.name.split(":")[1];
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (approval) { if (approval) {
@@ -442,6 +419,13 @@ async function handleRpc(method, params, origin) {
? { method, message: params[0], from: params[1] } ? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] }; : { method, message: params[1], from: params[0] };
if (method === "eth_sign") {
signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
}
const decision = await requestSignApproval( const decision = await requestSignApproval(
origin, origin,
hostname, hostname,
@@ -611,12 +595,39 @@ if (windowsApi && windowsApi.onRemoved) {
// Listen for messages from content scripts and popup // Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => { runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_RPC") { if (msg.type === "AUTISTMASK_RPC") {
handleRpc(msg.method, msg.params, msg.origin).then((response) => { // Derive origin from trusted sender info to prevent origin spoofing.
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
if (sender.origin) {
trustedOrigin = sender.origin;
} else if (sender.tab && sender.tab.url) {
try {
trustedOrigin = new URL(sender.tab.url).origin;
} catch {
// keep fallback
}
}
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
sendResponse(response); sendResponse(response);
}); });
return true; return true;
} }
// Validate that popup-only messages originate from the extension itself.
const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
];
if (POPUP_ONLY_TYPES.includes(msg.type)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") { if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (approval) { if (approval) {
@@ -681,7 +692,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break; if (wallet) break;
} }
if (!wallet) throw new Error("Wallet not found"); if (!wallet) throw new Error("Wallet not found");
const decrypted = await decryptWithPassword( // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
wallet.encryptedSecret, wallet.encryptedSecret,
msg.password, msg.password,
); );
@@ -690,6 +702,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex, addrIndex,
decrypted, decrypted,
); );
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider); const connected = signer.connect(provider);
const tx = await connected.sendTransaction(approval.txParams); const tx = await connected.sendTransaction(approval.txParams);
@@ -735,7 +751,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break; if (wallet) break;
} }
if (!wallet) throw new Error("Wallet not found"); if (!wallet) throw new Error("Wallet not found");
const decrypted = await decryptWithPassword( // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
wallet.encryptedSecret, wallet.encryptedSecret,
msg.password, msg.password,
); );
@@ -744,6 +761,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex, addrIndex,
decrypted, decrypted,
); );
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const sp = approval.signParams; const sp = approval.signParams;
let signature; let signature;

View File

@@ -13,6 +13,26 @@ if (typeof browser !== "undefined") {
(document.head || document.documentElement).appendChild(script); (document.head || document.documentElement).appendChild(script);
} }
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
})();
// Relay requests from the page to the background script // Relay requests from the page to the background script
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;

View File

@@ -9,7 +9,7 @@
const pending = {}; const pending = {};
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", (event) => { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return; if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data; const { id, result, error } = event.data;
@@ -24,7 +24,7 @@
}); });
// Listen for events pushed from the extension // Listen for events pushed from the extension
window.addEventListener("message", (event) => { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return; if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data; const { eventName, data } = event.data;
@@ -134,7 +134,7 @@
// Some dApps (wagmi) check this to confirm MetaMask-like behavior // Some dApps (wagmi) check this to confirm MetaMask-like behavior
_metamask: { _metamask: {
isUnlocked() { isUnlocked() {
return Promise.resolve(true); return Promise.resolve(provider.selectedAddress !== null);
}, },
}, },
}; };
@@ -155,21 +155,38 @@
"</svg>", "</svg>",
); );
const providerInfo = { let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
uuid: "f3c5b2a1-8d4e-4f6a-9c7b-1e2d3a4b5c6d",
name: "AutistMask", function buildProviderInfo() {
icon: ICON_SVG, return {
rdns: "berlin.sneak.autistmask", uuid: providerUuid,
}; name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
}
function announceProvider() { function announceProvider() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("eip6963:announceProvider", { new CustomEvent("eip6963:announceProvider", {
detail: Object.freeze({ info: providerInfo, provider }), detail: Object.freeze({
info: buildProviderInfo(),
provider,
}),
}), }),
); );
} }
// Listen for the persisted UUID from the content script
function onProviderUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
window.removeEventListener("message", onProviderUuid);
providerUuid = event.data.uuid;
announceProvider();
}
window.addEventListener("message", onProviderUuid);
window.addEventListener("eip6963:requestProvider", announceProvider); window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider(); announceProvider();
})(); })();

View File

@@ -71,7 +71,7 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<textarea <textarea
id="wallet-recovery-phrase" id="wallet-mnemonic"
rows="3" rows="3"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg resize-y" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg resize-y"
placeholder="word word word ..." placeholder="word word word ..."
@@ -374,6 +374,12 @@
</button> </button>
</div> </div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="bg-hover rounded-md mx-1 p-3 mb-3 text-xs hidden"
></div>
<!-- token-filtered transactions --> <!-- token-filtered transactions -->
<div class="mt-3"> <div class="mt-3">
<div class="border-b border-border pb-1 mb-1"> <div class="border-b border-border pb-1 mb-1">
@@ -457,12 +463,12 @@
</div> </div>
<!-- ERC-20 token contract (hidden for ETH) --> <!-- ERC-20 token contract (hidden for ETH) -->
<div id="confirm-token-section" class="mb-3 hidden"> <div
<div class="text-xs text-muted mb-1">Token contract</div> id="confirm-token-section"
<div class="bg-hover rounded-md mx-1 p-3 mb-3 text-xs hidden"
id="confirm-token-contract" >
class="text-xs break-all" <div class="font-bold mb-2">Token Contract</div>
></div> <div id="confirm-token-contract" class="break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -525,6 +531,7 @@
<!-- ============ TX SUCCESS ============ --> <!-- ============ TX SUCCESS ============ -->
<div id="view-success-tx" class="view hidden"> <div id="view-success-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Confirmed</h2> <h2 class="font-bold mb-2">Transaction Confirmed</h2>
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div> <div class="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div> <div id="success-tx-summary" class="font-bold"></div>
@@ -630,9 +637,10 @@
<div class="flex justify-center mb-3"> <div class="flex justify-center mb-3">
<canvas id="receive-qr"></canvas> <canvas id="receive-qr"></canvas>
</div> </div>
<div class="border border-border p-2 break-all mb-3 text-xs"> <div
<span id="receive-dot"></span> class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
<span id="receive-address" class="select-all"></span> >
<span id="receive-address-block" class="select-all"></span>
<span id="receive-etherscan-link"></span> <span id="receive-etherscan-link"></span>
</div> </div>
<button <button
@@ -702,9 +710,7 @@
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Wallets</h3> <h3 class="font-bold mb-1">Wallets</h3>
<p class="text-xs text-muted mb-2"> <div id="settings-wallet-list" class="mb-2"></div>
Add a new wallet from a recovery phrase or private key.
</p>
<button <button
id="btn-main-add-wallet" id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -713,6 +719,21 @@
</button> </button>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Tracked Tokens</h3>
<p class="text-xs text-muted mb-2">
ERC-20 tokens whose balances are tracked across all
addresses.
</p>
<div id="settings-tracked-tokens"></div>
<button
id="btn-settings-add-token"
class="border border-border px-2 py-1 mt-2 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add token
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3> <h3 class="font-bold mb-1">Display</h3>
<label <label
@@ -824,6 +845,108 @@
</div> </div>
</div> </div>
<!-- ============ DELETE WALLET CONFIRM ============ -->
<div id="view-delete-wallet-confirm" class="view hidden">
<button
id="btn-delete-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet</h2>
<p class="text-xs mb-3">
Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 hidden"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="delete-wallet-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to confirm"
/>
</div>
<button
id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm Delete
</button>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden">
<button
id="btn-settings-addtoken-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually.
</p>
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
</div>
<!-- manual contract address -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or enter contract address:</label
>
<input
type="text"
id="settings-addtoken-address"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
<div
id="settings-addtoken-info"
class="text-xs text-muted mt-1 hidden"
></div>
<button
id="btn-settings-addtoken-manual"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
</div>
</div>
<!-- ============ TRANSACTION DETAIL ============ --> <!-- ============ TRANSACTION DETAIL ============ -->
<div id="view-transaction" class="view hidden"> <div id="view-transaction" class="view hidden">
<button <button
@@ -832,7 +955,13 @@
> >
&lt; Back &lt; Back
</button> </button>
<h2 class="font-bold mb-2">Transaction</h2> <h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction
</h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div id="tx-detail-type" class="text-xs font-bold"></div>
</div>
<div class="mb-4"> <div class="mb-4">
<div class="text-xs text-muted mb-1">Status</div> <div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div> <div id="tx-detail-status" class="text-xs"></div>
@@ -857,10 +986,33 @@
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div> <div id="tx-detail-to" class="text-xs break-all"></div>
</div> </div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div
id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
</div>
<div class="mb-4"> <div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div> <div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div> <div id="tx-detail-hash" class="text-xs break-all"></div>
</div> </div>
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
class="text-xs break-all font-mono border border-border border-dashed p-2"
></div>
</div>
</div> </div>
<!-- ============ TRANSACTION APPROVAL ============ --> <!-- ============ TRANSACTION APPROVAL ============ -->
@@ -933,6 +1085,17 @@
wants you to sign a message. wants you to sign a message.
</p> </p>
<div
id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
></div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div> <div class="text-xs text-muted mb-1">Type</div>
<div id="approve-sign-type" class="text-xs font-bold"></div> <div id="approve-sign-type" class="text-xs font-bold"></div>

View File

@@ -20,6 +20,7 @@ const transactionDetail = require("./views/transactionDetail");
const receive = require("./views/receive"); const receive = require("./views/receive");
const addToken = require("./views/addToken"); const addToken = require("./views/addToken");
const settings = require("./views/settings"); const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken");
const approval = require("./views/approval"); const approval = require("./views/approval");
function renderWalletList() { function renderWalletList() {
@@ -60,6 +61,8 @@ const ctx = {
showConfirmTx: (txInfo) => confirmTx.show(txInfo), showConfirmTx: (txInfo) => confirmTx.show(txInfo),
showReceive: () => receive.show(), showReceive: () => receive.show(),
showTransactionDetail: (tx) => transactionDetail.show(tx), showTransactionDetail: (tx) => transactionDetail.show(tx),
showSettingsView: () => settings.show(),
showSettingsAddTokenView: () => settingsAddToken.show(),
}; };
// Views that can be fully re-rendered from persisted state. // Views that can be fully re-rendered from persisted state.
@@ -70,6 +73,7 @@ const RESTORABLE_VIEWS = new Set([
"address-token", "address-token",
"receive", "receive",
"settings", "settings",
"settings-addtoken",
"transaction", "transaction",
"success-tx", "success-tx",
"error-tx", "error-tx",
@@ -120,6 +124,9 @@ function restoreView() {
case "settings": case "settings":
settings.show(); settings.show();
break; break;
case "settings-addtoken":
settingsAddToken.show();
break;
case "transaction": case "transaction":
if (state.viewData && state.viewData.tx) { if (state.viewData && state.viewData.tx) {
transactionDetail.render(); transactionDetail.render();
@@ -182,7 +189,7 @@ async function init() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval"); const approvalId = params.get("approval");
if (approvalId) { if (approvalId) {
approval.show(parseInt(approvalId, 10)); approval.show(approvalId);
showView("approve-site"); showView("approve-site");
return; return;
} }
@@ -212,6 +219,7 @@ async function init() {
receive.init(ctx); receive.init(ctx);
addToken.init(ctx); addToken.init(ctx);
settings.init(ctx); settings.init(ctx);
settingsAddToken.init(ctx);
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");

View File

@@ -1,15 +1,15 @@
const { $, showView, showFlash } = require("./helpers"); const { $, showView, showFlash } = require("./helpers");
const { const {
generateRecoveryPhrase, generateMnemonic,
hdWalletFromRecoveryPhrase, hdWalletFromMnemonic,
isValidRecoveryPhrase, isValidMnemonic,
} = require("../../shared/wallet"); } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances"); const { scanForAddresses } = require("../../shared/balances");
function show() { function show() {
$("wallet-recovery-phrase").value = ""; $("wallet-mnemonic").value = "";
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden"); $("add-wallet-phrase-warning").classList.add("hidden");
@@ -18,19 +18,19 @@ function show() {
function init(ctx) { function init(ctx) {
$("btn-generate-phrase").addEventListener("click", () => { $("btn-generate-phrase").addEventListener("click", () => {
$("wallet-recovery-phrase").value = generateRecoveryPhrase(); $("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden"); $("add-wallet-phrase-warning").classList.remove("hidden");
}); });
$("btn-add-wallet-confirm").addEventListener("click", async () => { $("btn-add-wallet-confirm").addEventListener("click", async () => {
const recoveryPhrase = $("wallet-recovery-phrase").value.trim(); const mnemonic = $("wallet-mnemonic").value.trim();
if (!recoveryPhrase) { if (!mnemonic) {
showFlash( showFlash(
"Enter a recovery phrase or press the die to generate one.", "Enter a recovery phrase or press the die to generate one.",
); );
return; return;
} }
const words = recoveryPhrase.split(/\s+/); const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) { if (words.length !== 12 && words.length !== 24) {
showFlash( showFlash(
"Recovery phrase must be 12 or 24 words. You entered " + "Recovery phrase must be 12 or 24 words. You entered " +
@@ -39,7 +39,7 @@ function init(ctx) {
); );
return; return;
} }
if (!isValidRecoveryPhrase(recoveryPhrase)) { if (!isValidMnemonic(mnemonic)) {
showFlash("Invalid recovery phrase. Check for typos."); showFlash("Invalid recovery phrase. Check for typos.");
return; return;
} }
@@ -49,16 +49,15 @@ function init(ctx) {
showFlash("Please choose a password."); showFlash("Please choose a password.");
return; return;
} }
if (pw.length < 8) { if (pw.length < 12) {
showFlash("Password must be at least 8 characters."); showFlash("Password must be at least 12 characters.");
return; return;
} }
if (pw !== pw2) { if (pw !== pw2) {
showFlash("Passwords do not match."); showFlash("Passwords do not match.");
return; return;
} }
const { xpub, firstAddress } = const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
hdWalletFromRecoveryPhrase(recoveryPhrase);
const duplicate = state.wallets.find( const duplicate = state.wallets.find(
(w) => (w) =>
w.type === "hd" && w.type === "hd" &&
@@ -74,7 +73,7 @@ function init(ctx) {
); );
return; return;
} }
const encrypted = await encryptWithPassword(recoveryPhrase, pw); const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
const wallet = { const wallet = {
type: "hd", type: "hd",

View File

@@ -4,10 +4,9 @@ const {
showFlash, showFlash,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml, addressDotHtml,
addressTitle,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
@@ -86,6 +85,41 @@ function show() {
loadTransactions(addr.address); loadTransactions(addr.address);
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
@@ -117,11 +151,11 @@ async function loadTransactions(address) {
loadedTxs = txs; loadedTxs = txs;
// Collect unique counterparty addresses for ENS resolution. // Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
const counterparties = [ const counterparties = [
...new Set( ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
]; ];
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
@@ -152,14 +186,23 @@ function renderTransactions(txs) {
let html = ""; let html = "";
let i = 0; let i = 0;
for (const tx of txs) { for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from; // For swap transactions, show the user's own labelled wallet
// address instead of the contract address (see issue #55).
const counterparty =
tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel; const dirLabel = tx.directionLabel;
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol); : escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr); const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr); const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty); const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
@@ -167,7 +210,7 @@ function renderTransactions(txs) {
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted">${iso} (${ago})</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`; html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `</div>`; html += `</div>`;
i++; i++;

View File

@@ -6,13 +6,13 @@ const {
showView, showView,
showFlash, showFlash,
addressDotHtml, addressDotHtml,
addressTitle,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
balanceLine, balanceLine,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { const {
formatUsd, formatUsd,
getPrice, getPrice,
@@ -40,6 +40,41 @@ function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`; return `https://etherscan.io/address/${address}`;
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
let currentSymbol = null; let currentSymbol = null;
@@ -52,6 +87,7 @@ function show() {
// Determine token symbol and balance // Determine token symbol and balance
let symbol, amount, price; let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") { if (tokenId === "ETH") {
symbol = "ETH"; symbol = "ETH";
amount = parseFloat(addr.balance || "0"); amount = parseFloat(addr.balance || "0");
@@ -60,7 +96,11 @@ function show() {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(), (t) => t.address.toLowerCase() === tokenId.toLowerCase(),
); );
symbol = tb ? tb.symbol : "?"; symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
amount = tb ? parseFloat(tb.balance || "0") : 0; amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol); price = getPrice(symbol);
} }
@@ -97,6 +137,62 @@ function show() {
// Single token balance line (no tokenId — not clickable here) // Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price); $("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
if (tokenId !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const rawName =
(tb && tb.name) ||
(tracked && tracked.name) ||
(knownToken && knownToken.name) ||
null;
const rawSymbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
null;
const tokenName = rawName ? escapeHtml(rawName) : null;
const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null;
const tokenDecimals =
tb && tb.decimals != null
? tb.decimals
: tracked && tracked.decimals != null
? tracked.decimals
: knownToken && knownToken.decimals != null
? knownToken.decimals
: null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml +=
`<div class="flex items-center mb-2">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml;
contractInfo.classList.remove("hidden");
} else {
contractInfo.innerHTML = "";
contractInfo.classList.add("hidden");
}
// Transactions // Transactions
$("address-token-tx-list").innerHTML = $("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>'; '<div class="text-muted text-xs py-1">Loading...</div>';
@@ -142,11 +238,10 @@ async function loadTransactions(address, tokenId) {
loadedTxs = txs; loadedTxs = txs;
// Collect unique counterparty addresses for ENS resolution // Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
const counterparties = [ const counterparties = [
...new Set( ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
]; ];
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
@@ -179,12 +274,14 @@ function renderTransactions(txs) {
for (const tx of txs) { for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from; const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel; const dirLabel = tx.directionLabel;
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol); : escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr); const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr); const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty); const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
@@ -192,7 +289,7 @@ function renderTransactions(txs) {
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted">${iso} (${ago})</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`; html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `</div>`; html += `</div>`;
i++; i++;
@@ -219,6 +316,14 @@ function init(_ctx) {
} }
}); });
$("address-token-contract-info").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]");
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
}
});
$("btn-address-token-back").addEventListener("click", () => { $("btn-address-token-back").addEventListener("click", () => {
ctx.showAddressDetail(); ctx.showAddressDetail();
}); });

View File

@@ -1,4 +1,10 @@
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers"); const {
$,
addressDotHtml,
addressTitle,
escapeHtml,
showView,
} = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers"); const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
@@ -22,7 +28,15 @@ function approvalAddressHtml(address) {
const dot = addressDotHtml(address); const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`; const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`; const title = addressTitle(address, state.wallets);
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="break-all">${escapeHtml(address)}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
return html;
} }
function formatTxValue(val) { function formatTxValue(val) {
@@ -64,10 +78,12 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
); );
const isUnlimited = rawAmount === maxUint; const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited const amountStr = isUnlimited
? "Unlimited" ? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals)) + : amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
(tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Approval", name: "Token Approval",
@@ -86,7 +102,11 @@ function decodeCalldata(data, toAddress) {
value: spender, value: spender,
address: spender, address: spender,
}, },
{ label: "Amount", value: amountStr }, {
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
], ],
}; };
} }
@@ -94,9 +114,11 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") { if (parsed.name === "transfer") {
const to = parsed.args[0]; const to = parsed.args[0];
const rawAmount = parsed.args[1]; const rawAmount = parsed.args[1];
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
);
const amountStr = const amountStr =
formatTxValue(formatUnits(rawAmount, tokenDecimals)) + amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
(tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Transfer", name: "Token Transfer",
@@ -111,7 +133,11 @@ function decodeCalldata(data, toAddress) {
isToken: true, isToken: true,
}, },
{ label: "Recipient", value: to, address: to }, { label: "Recipient", value: to, address: to },
{ label: "Amount", value: amountStr }, {
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
], ],
}; };
} }
@@ -149,7 +175,7 @@ function showTxApproval(details) {
pendingTxDetails.to = d.address; pendingTxDetails.to = d.address;
} }
if (d.label === "Amount") { if (d.label === "Amount") {
pendingTxDetails.amount = d.value; pendingTxDetails.amount = d.rawValue || d.value;
} }
} }
if (token) { if (token) {
@@ -158,6 +184,15 @@ function showTxApproval(details) {
} }
} }
// Carry decoded calldata info through to success/error views
if (decoded) {
pendingTxDetails.decoded = {
name: decoded.name,
description: decoded.description,
details: decoded.details,
};
}
$("approve-tx-hostname").textContent = details.hostname; $("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress); $("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
@@ -294,6 +329,18 @@ function showSignApproval(details) {
} }
} }
// Display danger warning for eth_sign (raw hash signing)
const warningEl = $("approve-sign-danger-warning");
if (warningEl) {
if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning;
warningEl.classList.remove("hidden");
} else {
warningEl.textContent = "";
warningEl.classList.add("hidden");
}
}
$("approve-sign-password").value = ""; $("approve-sign-password").value = "";
$("approve-sign-error").classList.add("hidden"); $("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = false; $("btn-approve-sign").disabled = false;
@@ -373,6 +420,7 @@ function init(ctx) {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password, password: password,
}, },
(response) => { (response) => {
@@ -412,6 +460,7 @@ function init(ctx) {
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password, password: password,
}, },
(response) => { (response) => {
@@ -439,4 +488,4 @@ function init(ctx) {
}); });
} }
module.exports = { init, show }; module.exports = { init, show, decodeCalldata };

View File

@@ -95,10 +95,22 @@ function show(txInfo) {
// Token contract section (ERC-20 only) // Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section"); const tokenSection = $("confirm-token-section");
if (isErc20) { if (isErc20) {
const dot = addressDotHtml(txInfo.token);
const link = etherscanTokenLink(txInfo.token); const link = etherscanTokenLink(txInfo.token);
$("confirm-token-contract").innerHTML = $("confirm-token-contract").innerHTML =
escapeHtml(txInfo.token) + `<div class="flex items-center">` +
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; dot +
`<span class="break-all underline decoration-dashed cursor-pointer" id="confirm-token-contract-copy" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
if (txInfo.tokenName) {
$("confirm-token-contract").innerHTML +=
`<div class="mt-1"><span class="text-muted">Name:</span> ${escapeHtml(txInfo.tokenName)}</div>`;
}
if (txInfo.tokenSymbol) {
$("confirm-token-contract").innerHTML +=
`<div class="mt-1"><span class="text-muted">Symbol:</span> ${escapeHtml(txInfo.tokenSymbol)}</div>`;
}
tokenSection.classList.remove("hidden"); tokenSection.classList.remove("hidden");
} else { } else {
tokenSection.classList.add("hidden"); tokenSection.classList.add("hidden");
@@ -273,6 +285,13 @@ function hidePasswordModal() {
} }
function init(ctx) { function init(ctx) {
$("confirm-token-section").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]");
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
}
});
$("btn-confirm-send").addEventListener("click", () => { $("btn-confirm-send").addEventListener("click", () => {
showPasswordModal(); showPasswordModal();
}); });
@@ -334,8 +353,13 @@ function init(ctx) {
tx = await contract.transfer(pendingTx.to, amount); tx = await contract.transfer(pendingTx.to, amount);
} }
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash); txStatus.showWait(pendingTx, tx.hash);
} catch (e) { } catch (e) {
decryptedSecret = null;
const hash = tx ? tx.hash : null; const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message); txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} }

View File

@@ -0,0 +1,90 @@
const { $, showView, showFlash } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
let deleteWalletIndex = null;
let ctx = null;
function show(walletIdx) {
deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").classList.add("hidden");
showView("delete-wallet-confirm");
}
function init(_ctx) {
ctx = _ctx;
$("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null;
ctx.showSettingsView();
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
$("delete-wallet-flash").textContent =
"Please enter your password.";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
// Collect addresses to clean up from allowedSites/deniedSites
const addresses = (wallet.addresses || []).map((a) => a.address);
// Remove wallet
state.wallets.splice(walletIdx, 1);
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
deleteWalletIndex = null;
if (state.wallets.length === 0) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
await saveState();
showView("welcome");
} else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState();
ctx.renderWalletList();
ctx.showSettingsView();
showFlash("Wallet deleted.");
}
});
}
module.exports = { init, show };

View File

@@ -8,6 +8,8 @@ const {
} = require("../../shared/prices"); } = require("../../shared/prices");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
// When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match.
const VIEWS = [ const VIEWS = [
"welcome", "welcome",
"add-wallet", "add-wallet",
@@ -23,6 +25,8 @@ const VIEWS = [
"receive", "receive",
"add-token", "add-token",
"settings", "settings",
"delete-wallet-confirm",
"settings-addtoken",
"transaction", "transaction",
"approve-site", "approve-site",
"approve-tx", "approve-tx",
@@ -82,7 +86,7 @@ function showFlash(msg, duration = 2000) {
function balanceLine(symbol, amount, price, tokenId) { function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4); const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) : ""; const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;";
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : ""; const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
const clickClass = tokenId const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row" ? " cursor-pointer hover:bg-hover balance-row"
@@ -93,7 +97,7 @@ function balanceLine(symbol, amount, price, tokenId) {
`<span>${symbol}</span>` + `<span>${symbol}</span>` +
`<span>${qty}</span>` + `<span>${qty}</span>` +
`</span>` + `</span>` +
`<span class="text-right text-muted flex-1">${usd || "&nbsp;"}</span>` + `<span class="text-right text-muted flex-1">${usd}</span>` +
`</div>` `</div>`
); );
} }
@@ -147,41 +151,6 @@ function truncateMiddle(str, maxLen) {
return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half)); return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half));
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
// 16 colors evenly spaced around the hue wheel (22.5° apart), // 16 colors evenly spaced around the hue wheel (22.5° apart),
// all at HSL saturation 70%, lightness 50% for uniform vibrancy. // all at HSL saturation 70%, lightness 50% for uniform vibrancy.
const ADDRESS_COLORS = [ const ADDRESS_COLORS = [
@@ -254,6 +223,41 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`; return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
module.exports = { module.exports = {
$, $,
showError, showError,

View File

@@ -3,11 +3,12 @@ const {
showView, showView,
showFlash, showFlash,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml,
escapeHtml,
truncateMiddle,
isoDate, isoDate,
timeAgo, timeAgo,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const { state, saveState, currentAddress } = require("../../shared/state");
const { updateSendBalance, renderSendTokenSelect } = require("./send"); const { updateSendBalance, renderSendTokenSelect } = require("./send");
@@ -102,13 +103,22 @@ function renderHomeTxList(ctx) {
let html = ""; let html = "";
let i = 0; let i = 0;
for (const tx of homeTxs) { for (const tx of homeTxs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from; // For swap transactions, show the user's own labelled wallet
// address (the one that initiated the swap) instead of the
// contract address which is not useful in the list view.
const counterparty =
tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const dirLabel = tx.directionLabel; const dirLabel = tx.directionLabel;
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol); : escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = truncateMiddle(counterparty, maxAddr); const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr); const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty); const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
@@ -116,7 +126,7 @@ function renderHomeTxList(ctx) {
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`; html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted">${iso} (${ago})</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`; html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `</div>`; html += `</div>`;
i++; i++;

View File

@@ -30,8 +30,8 @@ function init(ctx) {
showFlash("Please choose a password."); showFlash("Please choose a password.");
return; return;
} }
if (pw.length < 8) { if (pw.length < 12) {
showFlash("Password must be at least 8 characters."); showFlash("Password must be at least 12 characters.");
return; return;
} }
if (pw !== pw2) { if (pw !== pw2) {

View File

@@ -1,4 +1,10 @@
const { $, showView, showFlash, addressDotHtml } = require("./helpers"); const {
$,
showView,
showFlash,
formatAddressHtml,
addressTitle,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
@@ -12,8 +18,12 @@ const EXT_ICON =
function show() { function show() {
const addr = currentAddress(); const addr = currentAddress();
const address = addr ? addr.address : ""; const address = addr ? addr.address : "";
$("receive-dot").innerHTML = address ? addressDotHtml(address) : ""; const title = address ? addressTitle(address, state.wallets) : null;
$("receive-address").textContent = address; const ensName = addr ? addr.ensName || null : null;
$("receive-address-block").innerHTML = address
? formatAddressHtml(address, ensName, null, title)
: "";
$("receive-address-block").dataset.full = address;
const link = address ? `https://etherscan.io/address/${address}` : ""; const link = address ? `https://etherscan.io/address/${address}` : "";
$("receive-etherscan-link").innerHTML = link $("receive-etherscan-link").innerHTML = link
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` ? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
@@ -50,8 +60,16 @@ function show() {
} }
function init(ctx) { function init(ctx) {
$("receive-address-block").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address").textContent; const addr = $("receive-address-block").dataset.full;
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");

View File

@@ -1,10 +1,20 @@
// Send view: collect To, Amount, Token. Then go to confirmation. // Send view: collect To, Amount, Token. Then go to confirmation.
const { $, showFlash, addressDotHtml, escapeHtml } = require("./helpers"); const {
$,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS } = require("../../shared/tokenList"); const {
KNOWN_SYMBOLS,
TOKEN_BY_ADDRESS,
resolveSymbol,
} = require("../../shared/tokenList");
const EXT_ICON = const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` + `<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -44,8 +54,15 @@ function updateSendBalance() {
const dot = addressDotHtml(addr.address); const dot = addressDotHtml(addr.address);
const link = `https://etherscan.io/address/${addr.address}`; const link = `https://etherscan.io/address/${addr.address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(addr.address, state.wallets);
let fromHtml = ""; let fromHtml = "";
if (addr.ensName) { if (title) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
if (addr.ensName) {
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
}
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else if (addr.ensName) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`; fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`; fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else { } else {
@@ -60,7 +77,11 @@ function updateSendBalance() {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
); );
const symbol = tb ? tb.symbol : "?"; const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
const bal = tb ? tb.balance || "0" : "0"; const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent = $("send-balance").textContent =
"Current balance: " + bal + " " + symbol; "Current balance: " + bal + " " + symbol;
@@ -107,12 +128,28 @@ function init(_ctx) {
let tokenSymbol = null; let tokenSymbol = null;
let tokenBalance = null; let tokenBalance = null;
let tokenName = null;
if (token !== "ETH") { if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
); );
tokenSymbol = tb ? tb.symbol : "?"; tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
tokenBalance = tb ? tb.balance || "0" : "0"; tokenBalance = tb ? tb.balance || "0" : "0";
// Resolve token name from balances, tracked tokens, or known list
const lower = token.toLowerCase();
tokenName =
(tb && tb.name) ||
(
(state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower,
) || {}
).name ||
(TOKEN_BY_ADDRESS.get(lower) || {}).name ||
null;
} }
ctx.showConfirmTx({ ctx.showConfirmTx({
@@ -124,6 +161,7 @@ function init(_ctx) {
balance: addr.balance, balance: addr.balance,
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance, tokenBalance: tokenBalance,
tokenName: tokenName,
}); });
}); });

View File

@@ -1,7 +1,8 @@
const { $, showView, showFlash } = require("./helpers"); const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants"); const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -38,10 +39,95 @@ function renderSiteList(containerId, siteMap, stateKey) {
}); });
} }
function renderTrackedTokens() {
const container = $("settings-tracked-tokens");
if (state.trackedTokens.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
state.trackedTokens.forEach((token, idx) => {
const label = token.name
? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")"
: escapeHtml(token.symbol);
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span>${label}</span>`;
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-token").forEach((btn) => {
btn.addEventListener("click", async () => {
const idx = parseInt(btn.dataset.idx, 10);
state.trackedTokens.splice(idx, 1);
await saveState();
renderTrackedTokens();
});
});
}
function renderWalletListSettings() {
const container = $("settings-wallet-list");
if (state.wallets.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">No wallets.</p>';
return;
}
let html = "";
state.wallets.forEach((wallet, idx) => {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
deleteWallet.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
const idx = parseInt(span.dataset.idx, 10);
const wallet = state.wallets[idx];
const input = document.createElement("input");
input.type = "text";
input.className =
"border border-border p-0 text-xs bg-bg text-fg w-full";
input.value = wallet.name || "Wallet " + (idx + 1);
span.replaceWith(input);
input.focus();
input.select();
const finish = async () => {
const val = input.value.trim();
if (val && val !== wallet.name) {
wallet.name = val;
await saveState();
}
renderWalletListSettings();
};
input.addEventListener("blur", finish);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
if (e.key === "Escape") {
input.value = wallet.name || "Wallet " + (idx + 1);
input.blur();
}
});
});
});
}
function show() { function show() {
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
renderSiteLists(); renderSiteLists();
renderWalletListSettings();
showView("settings"); showView("settings");
} }
@@ -55,6 +141,8 @@ function renderSiteLists() {
} }
function init(ctx) { function init(ctx) {
deleteWallet.init(ctx);
$("btn-save-rpc").addEventListener("click", async () => { $("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim(); const url = $("settings-rpc").value.trim();
if (!url) { if (!url) {
@@ -155,6 +243,11 @@ function init(ctx) {
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView); $("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(
"click",
ctx.showSettingsAddTokenView,
);
$("btn-settings-back").addEventListener("click", () => { $("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");

View File

@@ -0,0 +1,159 @@
const { $, showView, showFlash } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
let ctx;
function isTracked(address) {
const lower = address.toLowerCase();
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
}
function tokenLabel(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
}
function renderTop10() {
const el = $("settings-addtoken-top10");
el.innerHTML = getTopTokens(10)
.map((t) => {
const tracked = isTracked(t.address);
const cls = tracked
? "border border-border px-1 text-xs opacity-40 cursor-default"
: "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
return (
`<button class="settings-addtoken-quick ${cls}"` +
` data-address="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${t.symbol}</button>`
);
})
.join("");
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
(btn) => {
btn.addEventListener("click", async () => {
const token = {
address: btn.dataset.address,
symbol: btn.dataset.symbol,
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
});
},
);
}
function renderDropdown() {
const sel = $("settings-addtoken-select");
const tokens = getTopTokens(100);
let html = '<option value="">-- select --</option>';
for (const t of tokens) {
const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html +=
`<option value="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${label}</option>`;
}
sel.innerHTML = html;
}
function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").classList.add("hidden");
renderTop10();
renderDropdown();
showView("settings-addtoken");
}
function init(_ctx) {
ctx = _ctx;
$("btn-settings-addtoken-back").addEventListener("click", () => {
ctx.showSettingsView();
});
$("btn-settings-addtoken-select").addEventListener("click", async () => {
const sel = $("settings-addtoken-select");
const opt = sel.options[sel.selectedIndex];
if (!opt || !opt.value) {
showFlash("Please select a token.");
return;
}
if (isTracked(opt.value)) {
showFlash("Already tracked.");
return;
}
const token = {
address: opt.value,
symbol: opt.dataset.symbol,
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
});
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
const addr = $("settings-addtoken-address").value.trim();
if (!addr || !addr.startsWith("0x")) {
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
}
if (isTracked(addr)) {
showFlash("Already tracked.");
return;
}
if (isScamAddress(addr)) {
showFlash("This address is on a known scam/fraud list.");
return;
}
const infoEl = $("settings-addtoken-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(addr, state.rpcUrl);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
name: info.name,
});
await saveState();
showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = "";
infoEl.classList.add("hidden");
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
} catch (e) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
}
});
}
module.exports = { init, show };

View File

@@ -13,6 +13,8 @@ const {
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
const EXT_ICON = const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` + `<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -35,27 +37,35 @@ function blockieHtml(address) {
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center"` +
`>${EXT_ICON}</a>`
);
}
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
const dot = addressDotHtml(address); const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`; const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; const extLink = etherscanLinkHtml(link);
let html = `<div class="mb-1">${blockie}</div>`; let html = `<div class="mb-1">${blockie}</div>`;
if (title) { if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`; html += `<div class="font-bold">${escapeHtml(title)}</div>`;
} }
if (ensName) { if (ensName) {
html += html +=
`<div class="flex items-center">${title ? "" : dot}` + `<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") + copyableHtml(ensName, "") +
extLink +
`</div>` + `</div>` +
`<div class="break-all">` + `<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") + copyableHtml(address, "break-all") +
extLink +
`</div>`; `</div>`;
} else { } else {
html += html +=
`<div class="flex items-center">${title ? "" : dot}` + `<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") + copyableHtml(address, "break-all") +
extLink + extLink +
`</div>`; `</div>`;
@@ -65,7 +75,7 @@ function txAddressHtml(address, ensName, title) {
function txHashHtml(hash) { function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`; const link = `https://etherscan.io/tx/${hash}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink; return copyableHtml(hash, "break-all") + extLink;
} }
@@ -85,6 +95,9 @@ function show(tx) {
fromEns: tx.fromEns || null, fromEns: tx.fromEns || null,
toEns: tx.toEns || null, toEns: tx.toEns || null,
directionLabel: tx.directionLabel || null, directionLabel: tx.directionLabel || null,
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
}, },
}; };
render(); render();
@@ -121,6 +134,30 @@ function render() {
nativeEl.parentElement.classList.add("hidden"); nativeEl.parentElement.classList.add("hidden");
} }
// Show type label for contract interactions (Swap, Execute, etc.)
const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
$("tx-detail-time").textContent = $("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
@@ -137,6 +174,87 @@ function render() {
}); });
} }
async function loadCalldata(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
const wellEl = $("tx-detail-calldata-well");
const rawSection = $("tx-detail-rawdata-section");
const rawEl = $("tx-detail-rawdata");
if (!section || !actionEl || !detailsEl) return;
try {
const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash,
);
if (!resp.ok) return;
const txData = await resp.json();
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || "");
if (decoded) {
// Render decoded calldata matching approval view style
actionEl.textContent = decoded.name;
let detailsHtml = "";
if (decoded.description) {
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
}
for (const d of decoded.details || []) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then dot + address + Etherscan link
const dot = addressDotHtml(d.address);
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
}
const etherscanUrl = `https://etherscan.io/token/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else if (d.address) {
// Protocol/contract entry: show name + Etherscan link
const dot = addressDotHtml(d.address);
const etherscanUrl = `https://etherscan.io/address/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
detailsHtml += `</div>`;
}
detailsEl.innerHTML = detailsHtml;
if (wellEl) wellEl.classList.remove("hidden");
} else {
// Unknown contract call — show method name in well
const method = txData.method || "Unknown contract call";
actionEl.textContent = method;
detailsEl.innerHTML = "";
if (wellEl) wellEl.classList.remove("hidden");
}
// Always show raw data
if (rawSection && rawEl) {
rawEl.innerHTML = copyableHtml(inputData, "break-all");
rawSection.classList.remove("hidden");
}
section.classList.remove("hidden");
// Bind copy handlers for new elements (including raw data now outside section)
const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) {
container.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
}
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
}
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => { $("btn-tx-back").addEventListener("click", () => {

View File

@@ -5,8 +5,10 @@ const {
showView, showView,
showFlash, showFlash,
addressDotHtml, addressDotHtml,
addressTitle,
escapeHtml, escapeHtml,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -37,6 +39,13 @@ function toAddressHtml(address) {
const dot = addressDotHtml(address); const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`; const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets);
if (title) {
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all">${escapeHtml(address)}${extLink}</div>`
);
}
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`; return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
} }
@@ -113,11 +122,51 @@ function showSuccess(txInfo, txHash, blockNumber) {
to: txInfo.to, to: txInfo.to,
hash: txHash, hash: txHash,
blockNumber: blockNumber, blockNumber: blockNumber,
decoded: txInfo.decoded || null,
}; };
renderSuccess(); renderSuccess();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = "";
if (decoded.name) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
}
if (decoded.description) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
}
for (const d of decoded.details) {
html += `<div class="mb-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address);
} else {
html += toAddressHtml(d.address);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
return html;
}
function renderSuccess() { function renderSuccess() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.hash) return; if (!d || !d.hash) return;
@@ -125,6 +174,16 @@ function renderSuccess() {
$("success-tx-to").innerHTML = toAddressHtml(d.to); $("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber); $("success-tx-block").textContent = String(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash); $("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && d.decoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
}
attachCopyHandlers("view-success-tx"); attachCopyHandlers("view-success-tx");
showView("success-tx"); showView("success-tx");
} }

View File

@@ -85,6 +85,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
balances.push({ balances.push({
address: item.token.address_hash, address: item.token.address_hash,
name: item.token.name || "",
symbol: item.token.symbol || "???", symbol: item.token.symbol || "???",
decimals: decimals, decimals: decimals,
balance: bal, balance: bal,
@@ -123,15 +124,27 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
}), }),
); );
// ENS reverse lookup // ENS reverse lookup — only overwrite on success so that
// transient RPC errors don't wipe a previously resolved name.
updates.push( updates.push(
provider provider
.lookupAddress(addr.address) .lookupAddress(addr.address)
.then((name) => { .then((name) => {
addr.ensName = name || null; addr.ensName = name || null;
log.debugf(
"ENS reverse",
addr.address,
"->",
addr.ensName,
);
}) })
.catch(() => { .catch((e) => {
addr.ensName = null; log.errorf(
"ENS reverse failed",
addr.address,
e.message,
);
// Keep existing addr.ensName if we had one
}), }),
); );
@@ -192,6 +205,10 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
name = symbol; name = symbol;
} }
// Truncate to prevent storage of excessively long values from RPC
name = String(name).slice(0, 64);
symbol = String(symbol).slice(0, 12);
log.infof("Token resolved:", symbol, "decimals", Number(decimals)); log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) }; return { name, symbol, decimals: Number(decimals) };
} }

View File

@@ -1,5 +1,5 @@
const DEBUG = true; const DEBUG = true;
const DEBUG_RECOVERY_PHRASE = const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; "cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
@@ -22,7 +22,7 @@ const ERC20_ABI = [
module.exports = { module.exports = {
DEBUG, DEBUG,
DEBUG_RECOVERY_PHRASE, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL, DEFAULT_BLOCKSCOUT_URL,

View File

@@ -39,7 +39,7 @@ async function resolveEnsName(address, rpcUrl) {
return name; return name;
} catch (e) { } catch (e) {
log.errorf("ENS reverse lookup failed", address, e.message); log.errorf("ENS reverse lookup failed", address, e.message);
setCache(address, null); // Don't cache failures — let subsequent lookups retry
return null; return null;
} }
} }

View File

@@ -1,8 +1,10 @@
// Leveled logger. Outputs to console with [AutistMask] prefix. // Leveled logger. Outputs to console with [AutistMask] prefix.
// Level is DEBUG when the DEBUG constant is true, INFO otherwise. // Level is DEBUG when the DEBUG constant is true, INFO otherwise.
const { DEBUG } = require("./constants");
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const threshold = LEVELS.info; const threshold = DEBUG ? LEVELS.debug : LEVELS.info;
function emit(level, method, args) { function emit(level, method, args) {
if (LEVELS[level] >= threshold) { if (LEVELS[level] >= threshold) {

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,21 @@ function parseTx(tx, addrLower) {
if (token) { if (token) {
symbol = token.symbol; symbol = token.symbol;
} }
const label = method.charAt(0).toUpperCase() + method.slice(1); // Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([
"execute",
"swap",
"swapExactTokensForTokens",
"swapTokensForExactTokens",
"swapExactETHForTokens",
"swapTokensForExactETH",
"swapExactTokensForETH",
"swapETHForExactTokens",
"multicall",
]);
const label = SWAP_METHODS.has(method)
? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract"; direction = "contract";
directionLabel = label; directionLabel = label;
value = ""; value = "";
@@ -139,9 +153,18 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx // When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real // is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. Replace the normal tx with the token transfer. // amount and symbol. Replace the normal tx with the token transfer,
// but preserve contract call metadata (direction, label, method) so
// swaps and other contract interactions display correctly.
for (const tt of ttJson.items || []) { for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower); const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
}
txsByHash.set(parsed.hash, parsed); txsByHash.set(parsed.hash, parsed);
} }

View File

@@ -161,6 +161,157 @@ function decodeWrapEth(input) {
} }
} }
// V4 inner action IDs
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
const actions = getBytes(d[0]);
const params = d[1];
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
for (let i = 0; i < actions.length; i++) {
const actionId = actions[i];
try {
if (actionId === V4_SETTLE) {
// SETTLE: (address currency, uint256 maxAmount, bool payerIsUser)
const s = coder.decode(
["address", "uint256", "bool"],
params[i],
);
settleToken = s[0];
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
["address", "address", "uint256"],
params[i],
);
takeToken = t[0];
} else if (
actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE
) {
// Extract amounts from exact-in swap actions
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!settleToken) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !takeToken) {
takeToken = path[path.length - 1][0];
}
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
} catch {
// Fall through — SETTLE/TAKE will provide tokens
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
} catch {
// Fall through
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!takeToken) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !settleToken) {
settleToken = path[path.length - 1][0];
}
} catch {
// Fall through
}
} else {
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
// Fall through
}
}
}
} catch {
// Skip sub-actions we can't decode
}
}
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn,
amountOutMin,
};
} catch {
return null;
}
}
// Try to decode a Universal Router execute() call. // Try to decode a Universal Router execute() call.
// Returns { name, description, details } matching the format used by // Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute(). // the approval UI, or null if the calldata is not a recognised execute().
@@ -233,6 +384,19 @@ function decode(data, toAddress) {
} }
} }
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
if (!minOutput && v4.amountOutMin)
minOutput = v4.amountOutMin;
}
}
if (cmdId === 0x0c) { if (cmdId === 0x0c) {
hasUnwrapWeth = true; hasUnwrapWeth = true;
} }

View File

@@ -1,11 +1,11 @@
// Wallet operations: recovery phrase generation, HD derivation, signing. // Wallet operations: mnemonic generation, HD derivation, signing.
// All crypto delegated to ethers.js. // All crypto delegated to ethers.js.
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const { DEBUG, DEBUG_RECOVERY_PHRASE, BIP44_ETH_PATH } = require("./constants"); const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateRecoveryPhrase() { function generateMnemonic() {
if (DEBUG) return DEBUG_RECOVERY_PHRASE; if (DEBUG) return DEBUG_MNEMONIC;
const m = Mnemonic.fromEntropy( const m = Mnemonic.fromEntropy(
globalThis.crypto.getRandomValues(new Uint8Array(16)), globalThis.crypto.getRandomValues(new Uint8Array(16)),
); );
@@ -17,8 +17,8 @@ function deriveAddressFromXpub(xpub, index) {
return node.deriveChild(index).address; return node.deriveChild(index).address;
} }
function hdWalletFromRecoveryPhrase(recoveryPhrase) { function hdWalletFromMnemonic(mnemonic) {
const node = HDNodeWallet.fromPhrase(recoveryPhrase, "", BIP44_ETH_PATH); const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_PATH);
const xpub = node.neuter().extendedKey; const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address; const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress }; return { xpub, firstAddress };
@@ -41,15 +41,15 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
return new Wallet(decryptedSecret); return new Wallet(decryptedSecret);
} }
function isValidRecoveryPhrase(phrase) { function isValidMnemonic(mnemonic) {
return Mnemonic.isValidMnemonic(phrase); return Mnemonic.isValidMnemonic(mnemonic);
} }
module.exports = { module.exports = {
generateRecoveryPhrase, generateMnemonic,
deriveAddressFromXpub, deriveAddressFromXpub,
hdWalletFromRecoveryPhrase, hdWalletFromMnemonic,
addressFromPrivateKey, addressFromPrivateKey,
getSignerForAddress, getSignerForAddress,
isValidRecoveryPhrase, isValidMnemonic,
}; };

View File

@@ -1,9 +1,10 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers"); const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27. // AutistMask's first-ever swap, 2026-02-27.
@@ -256,6 +257,87 @@ describe("uniswap decoder", () => {
expect(amount.value).toBe("5.0000 USDT"); expect(amount.value).toBe("5.0000 USDT");
}); });
// This test validates the decodeV4Swap() fix: a V4 ERC20→ERC20 swap
// (USDT→USDC) where the token addresses are ONLY discoverable inside
// the V4_SWAP sub-actions (SETTLE/TAKE). Before decodeV4Swap() was added,
// command 0x10 was opaque and this would decode as "Uniswap Swap" with
// no token info (or "ETH → ETH"). Now it correctly shows "USDT → USDC".
test("decodes V4_SWAP ERC20→ERC20 tokens via SETTLE/TAKE (regression: #59)", () => {
// Build a V4_SWAP input with SETTLE(USDT) + SWAP_EXACT_IN_SINGLE + TAKE(USDC)
const V4_SETTLE = 0x0b;
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_TAKE = 0x0e;
// actions: SETTLE, SWAP_EXACT_IN_SINGLE, TAKE
const actions = new Uint8Array([
V4_SETTLE,
V4_SWAP_EXACT_IN_SINGLE,
V4_TAKE,
]);
// SETTLE params: (address currency, uint256 maxAmount, bool payerIsUser)
const settleParam = coder.encode(
["address", "uint256", "bool"],
[USDT_ADDR, 5000000n, true],
);
// SWAP_EXACT_IN_SINGLE params:
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const swapParam = coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[
USDT_ADDR,
USDC_ADDR,
100, // fee
1, // tickSpacing
"0x0000000000000000000000000000000000000000", // hooks
],
true, // zeroForOne
5000000n, // amountIn (5 USDT)
4900000n, // amountOutMin (4.9 USDC)
"0x", // hookData
],
],
);
// TAKE params: (address currency, address recipient, uint256 amount)
const takeParam = coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
);
// Encode the V4_SWAP input: (bytes actions, bytes[] params)
const v4Input = coder.encode(
["bytes", "bytes[]"],
[actions, [settleParam, swapParam, takeParam]],
);
// Build execute() with PERMIT2_PERMIT (0x0a) + V4_SWAP (0x10)
// The permit provides the input token, but V4_SWAP must provide
// the OUTPUT token — without decodeV4Swap, output would be unknown.
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0a, 0x10]),
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR), v4Input],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
// After fix: correctly identifies both tokens from V4 sub-actions
expect(result.name).toBe("Swap USDT \u2192 USDC");
const tokenIn = result.details.find((d) => d.label === "Token In");
expect(tokenIn.value).toContain("USDT");
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("V4 Swap");
});
test("handles unknown tokens gracefully", () => { test("handles unknown tokens gracefully", () => {
const fakeToken = "0x1111111111111111111111111111111111111111"; const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute( const data = buildExecute(

676
yarn.lock

File diff suppressed because it is too large Load Diff