Compare commits

..

87 Commits

Author SHA1 Message Date
user
d2f7284975 fix: persist confirm-tx view across popup close/reopen
All checks were successful
check / check (push) Successful in 22s
The confirm-tx view was not in RESTORABLE_VIEWS, so closing and
reopening the popup would fall back to the main view, losing the
pending transaction confirmation.

Save pendingTx data to state.viewData when showing confirm-tx and
add confirm-tx to the set of restorable views with proper restore
logic.

Closes #77
2026-02-28 12:25:55 -08:00
9444b06b52 Merge pull request 'fix: show token transaction history on address-token view (closes #72)' (#76) from fix/72-address-token-tx-history into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #76
2026-02-28 21:22:05 +01:00
c2fdb3e0c1 Merge branch 'main' into fix/72-address-token-tx-history
All checks were successful
check / check (push) Successful in 23s
2026-02-28 21:21:48 +01:00
0e68279037 Merge pull request 'feat: add export private key from address detail view' (#31) from feat/export-private-key into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #31
2026-02-28 21:10:17 +01:00
2bb7fc5786 Merge branch 'main' into feat/export-private-key
All checks were successful
check / check (push) Successful in 21s
2026-02-28 21:10:05 +01:00
user
4157732f4b fix: preserve multiple token transfers per tx hash for address-token view
All checks were successful
check / check (push) Successful in 21s
A single transaction (e.g. a DEX swap) can produce multiple ERC-20
token transfers. The transaction merger was keyed by tx hash alone,
so only the last token transfer survived. This meant the address-token
view's contract-address filter often matched nothing.

Use a composite key (hash + contract address) so all token transfers
are preserved. Also remove the bare normal-tx entry when it gets
replaced by token transfers to avoid duplicates.

Closes #72
2026-02-28 12:08:47 -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
user
0c1150ac4d fix: style private key as red well, remove explicit copy text
All checks were successful
check / check (push) Successful in 22s
- Replace dashed border with light red well (bg-danger-well) and rounded corners
- Remove redundant 'Click to copy.' paragraph
- Add --color-danger-well theme token
2026-02-28 11:54:20 -08: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
72a4dd3382 Merge branch 'main' into feat/export-private-key
All checks were successful
check / check (push) Successful in 22s
2026-02-28 20:44:21 +01:00
user
d3d9f9a8b0 fix: export-privkey view address display consistency
All checks were successful
check / check (push) Successful in 22s
Add blockie identicon, wallet/address title, and color dot with full
address display to the export-privkey view, matching the pattern used
by AddressDetail and other views. Address is click-to-copy.
2026-02-28 11:41:28 -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
16f9e98b25 Merge branch 'main' into feat/export-private-key
All checks were successful
check / check (push) Successful in 22s
2026-02-28 20:31:50 +01: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
676109860a Merge branch 'main' into feat/export-private-key
All checks were successful
check / check (push) Successful in 22s
2026-02-28 18:08:33 +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
user
3daba279d2 style: rework overflow menu to look like menu items, not buttons
All checks were successful
check / check (push) Successful in 21s
- Menu items now use text-xs font-light for smaller, lighter type
  that's clearly distinct from the pushbuttons in the action row
- The ··· button stays visually inverted (bg-fg text-bg) while the
  dropdown menu is open, reverting when closed
- Menu items styled as plain text rows with subtle hover background,
  no borders or button-like appearance
2026-02-28 08:39:57 -08:00
clawbot
70a8ac6f99 style: dropdown menu with subtle grey hover and list padding
All checks were successful
check / check (push) Successful in 22s
Use bg-hover token for grey mouseover instead of full fg/bg
inversion. Add py-1 padding to dropdown container and px-4 to
items for proper list appearance with margin around items.
2026-02-28 08:29:12 -08:00
user
68bd909345 fix: make overflow menu auto-width to prevent text wrapping
All checks were successful
check / check (push) Successful in 22s
2026-02-28 04:39:45 -08:00
user
91c3b4e394 refactor: move Export Private Key into overflow menu
Some checks are pending
check / check (push) Waiting to run
Replace the muted text link at the bottom of AddressDetail with a
'···' overflow/more button in the action button row. Clicking it
opens a dropdown with 'Export Private Key' as an option. Clicking
outside closes the dropdown. The pattern is reusable for future
secondary actions.
2026-02-28 04:27:56 -08:00
user
41794f8bf5 fix: make export key a subtle link, add view to VIEWS array
All checks were successful
check / check (push) Successful in 22s
- Moved 'Export private key' from prominent button row to a small
  muted text link at the bottom of the address detail view
- Added 'export-privkey' to the VIEWS array in helpers.js — this was
  the cause of the blank view (showView toggled all known views but
  didn't know about export-privkey, so it was never unhidden)
2026-02-28 01:37:45 -08:00
user
ca78da2e07 feat: add export private key from address detail view
All checks were successful
check / check (push) Successful in 22s
Adds an 'Export Private Key' button to the address detail view.
Clicking it opens a password confirmation screen; after verification,
the derived private key is displayed in a copyable field with a
security warning. The key is cleared when navigating away.

Closes #19
2026-02-28 01:19:36 -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
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
26 changed files with 1451 additions and 576 deletions

1
.gitignore vendored
View File

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

View File

@@ -93,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) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 400;
const popupHeight = 500;
const popupWidth = 360;
const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => {
const opts = {
@@ -148,7 +150,9 @@ function requestApproval(origin, hostname) {
}
// 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) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
@@ -160,27 +164,14 @@ function requestTxApproval(origin, hostname, txParams) {
type: "tx",
};
if (actionApi && typeof actionApi.setPopup === "function") {
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
}
}
openApprovalWindow(id);
});
}
// 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
// popup URL is still set, so the user can click the toolbar icon to respond.
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
@@ -192,27 +183,14 @@ function requestSignApproval(origin, hostname, signParams) {
type: "sign",
};
if (actionApi && typeof actionApi.setPopup === "function") {
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
}
}
openApprovalWindow(id);
});
}
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals are NOT auto-rejected on disconnect because toolbar
// popups naturally close on focus loss and the user can reopen them.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];

View File

@@ -13,6 +13,26 @@ if (typeof browser !== "undefined") {
(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
window.addEventListener("message", (event) => {
if (event.source !== window) return;

View File

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

View File

@@ -305,6 +305,26 @@
>
+ Token
</button>
<div class="relative">
<button
id="btn-more-menu"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
aria-label="More actions"
>
&middot;&middot;&middot;
</button>
<div
id="more-menu-dropdown"
class="hidden absolute right-0 top-full mt-1 border border-border bg-bg z-50 whitespace-nowrap py-1"
>
<button
id="btn-export-privkey"
class="block w-full text-left px-4 py-1.5 text-xs font-light text-muted hover:bg-hover hover:text-fg cursor-pointer"
>
Export Private Key
</button>
</div>
</div>
</div>
<!-- transactions -->
@@ -318,6 +338,60 @@
</div>
</div>
<!-- ============ EXPORT PRIVATE KEY VIEW ============ -->
<div id="view-export-privkey" class="view hidden">
<button
id="btn-export-privkey-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
id="export-privkey-jazzicon"
class="flex justify-center mt-1 mb-3"
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<p class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 hidden"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="export-privkey-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="export-privkey-result" class="hidden">
<div
id="export-privkey-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ ADDRESS-TOKEN DETAIL VIEW ============ -->
<div id="view-address-token" class="view hidden">
<button
@@ -358,12 +432,6 @@
</div>
</div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="border-b border-border-light pb-2 mb-2 text-xs hidden"
></div>
<!-- actions -->
<div class="flex gap-2 mb-3">
<button
@@ -380,6 +448,12 @@
</button>
</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 -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
@@ -531,6 +605,7 @@
<!-- ============ TX SUCCESS ============ -->
<div id="view-success-tx" class="view hidden">
<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="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div>
@@ -636,9 +711,10 @@
<div class="flex justify-center mb-3">
<canvas id="receive-qr"></canvas>
</div>
<div class="border border-border p-2 break-all mb-3 text-xs">
<span id="receive-dot"></span>
<span id="receive-address" class="select-all"></span>
<div
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
>
<span id="receive-address-block" class="select-all"></span>
<span id="receive-etherscan-link"></span>
</div>
<button
@@ -841,39 +917,41 @@
</p>
<div id="settings-denied-sites"></div>
</div>
</div>
<div
id="delete-wallet-confirm"
class="hidden bg-well p-3 mx-1 mb-3 border border-dashed border-border"
<!-- ============ 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"
>
<h3 class="font-bold mb-1">Confirm Deletion</h3>
<p class="text-xs text-muted mb-2">
Delete <strong id="delete-wallet-name"></strong>? This
is permanent. Any funds will be unrecoverable without
your recovery phrase.
</p>
<p class="text-xs mb-2">Enter your password to confirm:</p>
&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 text-sm bg-bg text-fg mb-2"
placeholder="Password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to confirm"
/>
<div class="flex gap-2">
<button
id="btn-delete-wallet-confirm"
class="border border-border text-fg px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Delete
</button>
<button
id="btn-delete-wallet-cancel"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
</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 ============ -->
@@ -951,7 +1029,13 @@
>
&lt; Back
</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="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
@@ -976,10 +1060,33 @@
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></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="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></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>
<!-- ============ TRANSACTION APPROVAL ============ -->

View File

@@ -75,6 +75,7 @@ const RESTORABLE_VIEWS = new Set([
"settings",
"settings-addtoken",
"transaction",
"confirm-tx",
"success-tx",
"error-tx",
]);
@@ -134,6 +135,13 @@ function restoreView() {
fallbackView();
}
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.show(state.viewData.pendingTx);
} else {
fallbackView();
}
break;
case "success-tx":
if (state.viewData && state.viewData.hash) {
txStatus.renderSuccess();
@@ -189,7 +197,7 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
approval.show(parseInt(approvalId, 10));
approval.show(approvalId);
showView("approve-site");
return;
}

View File

@@ -11,6 +11,7 @@
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
--color-danger-well: #fef2f2;
--color-section: #dddddd;
}

View File

@@ -4,6 +4,7 @@ const {
showFlash,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -17,6 +18,8 @@ const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
let ctx;
@@ -150,11 +153,11 @@ async function loadTransactions(address) {
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 = [
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
if (counterparties.length > 0) {
try {
@@ -185,14 +188,23 @@ function renderTransactions(txs) {
let html = "";
let i = 0;
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 title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
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 dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -255,6 +267,102 @@ function init(_ctx) {
});
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
// More menu dropdown
const moreBtn = $("btn-more-menu");
const moreDropdown = $("more-menu-dropdown");
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = !moreDropdown.classList.toggle("hidden");
moreBtn.classList.toggle("bg-fg", isOpen);
moreBtn.classList.toggle("text-bg", isOpen);
});
document.addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
});
moreDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
$("export-privkey-dot").innerHTML = addressDotHtml(addr.address);
$("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = "";
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
});
$("btn-export-privkey-confirm").addEventListener("click", async () => {
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").classList.remove("hidden");
return;
}
const wallet = state.wallets[state.selectedWallet];
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
secret,
);
const privateKey = signer.privateKey;
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").classList.add("hidden");
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
}
});
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
show();
});
}
module.exports = { init, show };

View File

@@ -6,11 +6,13 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
@@ -85,6 +87,7 @@ function show() {
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = "ETH";
amount = parseFloat(addr.balance || "0");
@@ -93,7 +96,11 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = tb ? tb.symbol : "?";
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol);
}
@@ -136,36 +143,49 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tokenName = tb && tb.name ? escapeHtml(tb.name) : null;
const tokenSymbol = tb && tb.symbol ? escapeHtml(tb.symbol) : null;
const tokenDecimals = tb && tb.decimals != null ? tb.decimals : null;
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)}`;
let infoHtml =
`<div class="font-bold mb-1">Token Contract</div>` +
`<div class="flex items-center mb-1">${dot}` +
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>`;
const details = [];
if (tokenName)
details.push(`<span class="text-muted">Name:</span> ${tokenName}`);
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol)
details.push(
`<span class="text-muted">Symbol:</span> ${tokenSymbol}`,
);
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null)
details.push(
`<span class="text-muted">Decimals:</span> ${tokenDecimals}`,
);
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
if (tokenHolders != null)
details.push(
`<span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}`,
);
if (details.length > 0) {
infoHtml += `<div class="flex flex-wrap gap-x-3 gap-y-1">${details.join("")}</div>`;
}
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 {
@@ -218,11 +238,10 @@ async function loadTransactions(address, tokenId) {
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 = [
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
if (counterparties.length > 0) {
try {
@@ -255,12 +274,14 @@ function renderTransactions(txs) {
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
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 dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";

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 { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants");
@@ -22,7 +28,15 @@ function approvalAddressHtml(address) {
const dot = addressDotHtml(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>`;
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) {
@@ -64,10 +78,12 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Approval",
@@ -86,7 +102,11 @@ function decodeCalldata(data, toAddress) {
value: 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") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
);
const amountStr =
formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
@@ -111,7 +133,11 @@ function decodeCalldata(data, toAddress) {
isToken: true,
},
{ 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;
}
if (d.label === "Amount") {
pendingTxDetails.amount = d.value;
pendingTxDetails.amount = d.rawValue || d.value;
}
}
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-from").innerHTML = approvalAddressHtml(state.activeAddress);
@@ -453,4 +488,4 @@ function init(ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, decodeCalldata };

View File

@@ -18,7 +18,7 @@ const {
addressDotHtml,
escapeHtml,
} = require("./helpers");
const { state } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
@@ -219,6 +219,10 @@ function show(txInfo) {
$("confirm-fee-amount").textContent = "Estimating...";
showView("confirm-tx");
// Persist txInfo so the view survives popup close/reopen
state.viewData = { pendingTx: txInfo };
saveState();
estimateGas(txInfo);
}

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

@@ -25,11 +25,13 @@ const VIEWS = [
"receive",
"add-token",
"settings",
"delete-wallet-confirm",
"settings-addtoken",
"transaction",
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
];
function $(id) {

View File

@@ -6,6 +6,7 @@ const {
isoDate,
timeAgo,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -102,13 +103,22 @@ function renderHomeTxList(ctx) {
let html = "";
let i = 0;
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 amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets);
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 dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";

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 QRCode = require("qrcode");
@@ -12,8 +18,12 @@ const EXT_ICON =
function show() {
const addr = currentAddress();
const address = addr ? addr.address : "";
$("receive-dot").innerHTML = address ? addressDotHtml(address) : "";
$("receive-address").textContent = address;
const title = address ? addressTitle(address, state.wallets) : null;
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}` : "";
$("receive-etherscan-link").innerHTML = link
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
@@ -50,8 +60,16 @@ function show() {
}
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", () => {
const addr = $("receive-address").textContent;
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");

View File

@@ -1,10 +1,16 @@
// 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");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS } = require("../../shared/tokenList");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -44,8 +50,15 @@ function updateSendBalance() {
const dot = addressDotHtml(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 title = addressTitle(addr.address, state.wallets);
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="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else {
@@ -60,7 +73,11 @@ function updateSendBalance() {
const tb = (addr.tokenBalances || []).find(
(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";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
@@ -111,7 +128,11 @@ function init(_ctx) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = tb ? tb.symbol : "?";
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
tokenBalance = tb ? tb.balance || "0" : "0";
}

View File

@@ -2,7 +2,7 @@ const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
const { decryptWithPassword } = require("../../shared/vault");
const deleteWallet = require("./deleteWallet");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -66,8 +66,6 @@ function renderTrackedTokens() {
});
}
let deleteWalletIndex = null;
function renderWalletListSettings() {
const container = $("settings-wallet-list");
if (state.wallets.length === 0) {
@@ -78,20 +76,47 @@ function renderWalletListSettings() {
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>${name}</span>`;
html += `<span class="btn-delete-wallet border-b border-dashed border-fg text-fg cursor-pointer" data-idx="${idx}">[delete]</span>`;
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];
deleteWalletIndex = idx;
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (idx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-confirm").classList.remove("hidden");
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();
}
});
});
});
}
@@ -102,8 +127,6 @@ function show() {
renderTrackedTokens();
renderSiteLists();
renderWalletListSettings();
deleteWalletIndex = null;
$("delete-wallet-confirm").classList.add("hidden");
showView("settings");
}
@@ -118,6 +141,8 @@ function renderSiteLists() {
}
function init(ctx) {
deleteWallet.init(ctx);
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();
if (!url) {
@@ -227,72 +252,6 @@ function init(ctx) {
ctx.renderWalletList();
showView("main");
});
$("btn-delete-wallet-cancel").addEventListener("click", () => {
$("delete-wallet-confirm").classList.add("hidden");
$("delete-wallet-password").value = "";
deleteWalletIndex = null;
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
showFlash("Password required.");
return;
}
if (deleteWalletIndex === null) {
showFlash("No wallet selected for deletion.");
return;
}
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encrypted, pw);
} catch (_e) {
showFlash("Wrong password.");
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();
$("delete-wallet-confirm").classList.add("hidden");
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();
$("delete-wallet-confirm").classList.add("hidden");
showFlash("Wallet deleted.");
renderWalletListSettings();
ctx.renderWalletList();
showView("main");
}
});
}
module.exports = { init, show, renderSiteLists };

View File

@@ -13,6 +13,8 @@ const {
} = require("./helpers");
const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<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">`;
}
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center"` +
`>${EXT_ICON}</a>`
);
}
function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
const dot = addressDotHtml(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>`;
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
}
if (ensName) {
html +=
`<div class="flex items-center">${title ? "" : dot}` +
`<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") +
extLink +
`</div>` +
`<div class="break-all">` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
} else {
html +=
`<div class="flex items-center">${title ? "" : dot}` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
@@ -65,7 +75,7 @@ function txAddressHtml(address, ensName, title) {
function txHashHtml(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;
}
@@ -85,6 +95,9 @@ function show(tx) {
fromEns: tx.fromEns || null,
toEns: tx.toEns || null,
directionLabel: tx.directionLabel || null,
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
},
};
render();
@@ -121,6 +134,30 @@ function render() {
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 =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("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) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {

View File

@@ -5,8 +5,10 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -37,6 +39,13 @@ function toAddressHtml(address) {
const dot = addressDotHtml(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 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>`;
}
@@ -113,11 +122,51 @@ function showSuccess(txInfo, txHash, blockNumber) {
to: txInfo.to,
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
};
renderSuccess();
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() {
const d = state.viewData;
if (!d || !d.hash) return;
@@ -125,6 +174,16 @@ function renderSuccess() {
$("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber);
$("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");
showView("success-tx");
}

View File

@@ -85,6 +85,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
balances.push({
address: item.token.address_hash,
name: item.token.name || "",
symbol: item.token.symbol || "???",
decimals: decimals,
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(
provider
.lookupAddress(addr.address)
.then((name) => {
addr.ensName = name || null;
log.debugf(
"ENS reverse",
addr.address,
"->",
addr.ensName,
);
})
.catch(() => {
addr.ensName = null;
.catch((e) => {
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;
}
// 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));
return { name, symbol, decimals: Number(decimals) };
}

View File

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

View File

@@ -3645,10 +3645,27 @@ async function getTopTokenPrices(n) {
return prices;
}
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase();
const tb = (tokenBalances || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tb && tb.symbol) return tb.symbol;
const known = TOKEN_BY_ADDRESS.get(lower);
if (known && known.symbol) return known.symbol;
const tracked = (trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tracked && tracked.symbol) return tracked.symbol;
return lower.slice(0, 10) + "\u2026";
}
module.exports = {
TOKENS,
TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS,
getTopTokens,
getTopTokenPrices,
resolveSymbol,
};

View File

@@ -37,7 +37,21 @@ function parseTx(tx, addrLower) {
if (token) {
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";
directionLabel = label;
value = "";
@@ -139,10 +153,26 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// 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
// amount and symbol. Replace the normal tx with the token transfer.
// amount and symbol. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
txsByHash.set(parsed.hash, parsed);
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;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];

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.
// Returns { name, description, details } matching the format used by
// 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) {
hasUnwrapWeth = true;
}

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 ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27.
@@ -256,6 +257,87 @@ describe("uniswap decoder", () => {
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", () => {
const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute(

676
yarn.lock

File diff suppressed because it is too large Load Diff