Compare commits

..

68 Commits

Author SHA1 Message Date
user
8c071ae508 fix: never collapse warning container — always reserve space to prevent layout shift
All checks were successful
check / check (push) Successful in 10s
Replace display:none with persistent visibility:hidden so the warning
area occupies the same vertical space regardless of API result.
This eliminates the layout shift that occurred when the container was
collapsed after the recipient history check returned.
2026-02-28 15:26:49 -08:00
user
a3c2b8227a fix: zero-tx warning layout shift and contract address false positive
- Reserve space for the warning upfront using visibility:hidden instead
  of display:none, preventing layout shift per README policy
- Move warning HTML to index.html as a static element rather than
  injecting dynamically
- Skip warning for contract addresses (check getCode first) since
  getTransactionCount only returns outgoing tx nonce
- Collapse reserved space when warning is not needed (address has
  history, is a contract, or on RPC error)
2026-02-28 15:26:44 -08:00
user
f9f3e7b85a feat: show red warning when sending to address with zero tx history
On the confirm-tx view, asynchronously check the recipient address
transaction count via getTransactionCount(). If zero, display a
prominent red warning advising the user to double-check the address.

Closes #82
2026-02-28 15:26:44 -08:00
812fc01a98 Merge pull request 'feat: add etherscan link and click-to-copy on block number in success-tx view' (#102) from issue-99-block-number-link into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #102
2026-03-01 00:23:07 +01:00
user
811c125cb9 fix: remove click-to-copy from timestamps in list views
All checks were successful
check / check (push) Successful in 22s
List view rows (home, addressDetail, addressToken) should only be clickable
as a whole to navigate to the detail view. Click-to-copy on individual
elements belongs only in the transaction detail view.

Reverts timestamp click-to-copy changes in list views per review feedback.
Keeps blockNumberHtml() and detail-view timestamp changes.
2026-02-28 15:21:13 -08:00
user
3005813f2c feat: add click-to-copy on timestamps in all transaction list views
All checks were successful
check / check (push) Successful in 9s
Adds click-to-copy (copies ISO date string) to timestamp displays in:
- home view (relative time ago)
- addressDetail view (relative time ago)
- addressToken view (relative time ago)
- transactionDetail view (full ISO date)

All timestamps now show dashed underline to indicate copyability,
matching the existing UX pattern for addresses, tx hashes, and
block numbers.
2026-02-28 14:40:11 -08:00
user
5565e76796 feat: add etherscan link and click-to-copy on block number in success-tx view
All checks were successful
check / check (push) Successful in 22s
Block numbers are blockchain entities like addresses and tx hashes. They now
receive the same treatment: click-to-copy and an external link icon pointing
to etherscan.io/block/{number}.

Closes #99
2026-02-28 14:09:23 -08:00
dc8ec7d28f Merge pull request 'fix: make success-tx addresses clickable, fix USDT ETH bug, nest decoded details (closes #80)' (#94) from fix/issue-80-success-tx-display into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #94
2026-02-28 22:57:37 +01:00
user
2fbed343db fix: make success-tx addresses clickable, fix USDT ETH bug, nest decoded details (closes #80)
All checks were successful
check / check (push) Successful in 22s
- Add underline + click-to-copy (data-copy) to addresses in toAddressHtml()
  so they match the style used everywhere else in the extension
- Fix 'USDT ETH' display: add rawValue to Uniswap decoder Amount details
  and extract Token In info for proper symbol resolution in approval.js
- Hide redundant top-level Amount/To when decoded details are present
  (they already show the same info inside the decoded section)
- Wrap decoded calldata details in a bordered well for visual separation
2026-02-28 13:36:19 -08:00
699e080e3e Merge pull request 'fix: replace confirm-tx password modal with inline field (closes #78)' (#83) from fix/issue-78-inline-password into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #83
2026-02-28 22:28:18 +01:00
user
8f2bf9618e fix: replace confirm-tx password modal with inline field (closes #78)
All checks were successful
check / check (push) Successful in 22s
Replace the modal overlay password dialog in the confirm-tx view with
an inline password field, matching the pattern used by approve-tx and
approve-sign views for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:27:49 +01:00
069981baa0 Merge pull request 'fix: disable export-privkey and delete-wallet buttons during async processing' (#89) from fix/issue-86-disable-buttons-during-async into main
All checks were successful
check / check (push) Successful in 22s
Reviewed-on: #89
2026-02-28 22:27:27 +01:00
clawbot
886cd38a9b fix: disable export-privkey and delete-wallet buttons during async processing
All checks were successful
check / check (push) Successful in 9s
Closes #86
2026-02-28 22:27:09 +01:00
438d915f73 Merge pull request 'persist confirm-tx view across popup close/reopen (closes #77)' (#79) from fix/issue-77-confirm-tx-persist into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #79
2026-02-28 22:26:39 +01:00
user
78f961f416 persist confirm-tx view across popup close/reopen (closes #77)
All checks were successful
check / check (push) Successful in 23s
Add confirm-tx to RESTORABLE_VIEWS and save pendingTx in
state.viewData so the confirmation screen survives the popup
lifecycle. On restore, re-render the full confirmation view
including gas estimate.
2026-02-28 22:26:07 +01:00
6a214f1c58 Merge pull request 'fix: approve-tx/approve-sign error divs consistency with confirm-tx' (#92) from fix/84-approve-error-div-consistency into main
All checks were successful
check / check (push) Successful in 22s
Reviewed-on: #92
2026-02-28 22:25:37 +01:00
ad2ce3d8ff Merge branch 'main' into fix/84-approve-error-div-consistency
All checks were successful
check / check (push) Successful in 8s
2026-02-28 22:25:21 +01:00
b826279d8f Merge pull request 'fix: clear password field and error in showTxApproval' (#91) from fix/issue-85-clear-approve-tx-password into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #91
2026-02-28 22:24:55 +01:00
user
20ced62e1a fix: approve-tx/approve-sign error divs consistency with confirm-tx
All checks were successful
check / check (push) Successful in 22s
Add min-h-[1.25rem] and border styling to approve-tx-error and
approve-sign-error divs to prevent layout shift, matching the pattern
used by modal-password-error in confirm-tx view.

Replace direct DOM classList manipulation with showError()/hideError()
helpers from helpers.js for consistency.

Closes #84
2026-02-28 13:13:23 -08:00
user
9b69a60cca fix: clear password field and error in showTxApproval
All checks were successful
check / check (push) Successful in 22s
Clears #approve-tx-password value and hides #approve-tx-error when the
transaction approval view is shown, matching the pattern used in
showSignApproval and confirmTx.show.

Closes #85
2026-02-28 13:10:17 -08:00
3b6b18d168 Merge pull request 'fix: validate destination address on send view (closes #67)' (#68) from fix/67-validate-send-address into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #68
2026-02-28 21:38:22 +01:00
33ae5784e2 Merge branch 'main' into fix/67-validate-send-address
All checks were successful
check / check (push) Successful in 22s
2026-02-28 21:37:38 +01:00
cd30d94040 Merge pull request 'fix: make token contract display on confirm-tx consistent with other views' (#73) from fix/confirm-tx-contract-display into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #73
2026-02-28 21:33:53 +01:00
62bb54556c Merge branch 'main' into fix/confirm-tx-contract-display
All checks were successful
check / check (push) Successful in 22s
2026-02-28 21:33:24 +01:00
8e1856415a Merge branch 'main' into fix/67-validate-send-address
All checks were successful
check / check (push) Successful in 23s
2026-02-28 21:25:08 +01: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
user
9de7791553 fix: reset validation state when navigating to send view
All checks were successful
check / check (push) Successful in 22s
Clear the error/warning text and disable the review button when entering
the send view from home, address detail, or address token views. This
prevents stale validation messages from persisting after leaving and
returning to the send view.
2026-02-28 12:17:52 -08:00
user
ef2f862d23 fix: validate destination address on send view
- Validate Ethereum addresses (0x + 40 hex chars) and ENS names
- EIP-55 checksum validation for mixed-case addresses
- Block sending to zero address (0x0000...0000)
- Warn when sending to own address (allow but show warning)
- Inline error messages with reserved space (no layout shift)
- Disable Review button while address is invalid

Closes #67
2026-02-28 12:17:52 -08:00
a655c546b7 fix: make token contract display on confirm-tx consistent with other views
All checks were successful
check / check (push) Successful in 22s
Add color dot (addressDotHtml), dashed underline styling, and click-to-copy
functionality to the token contract address on the confirm-tx page, matching
the display pattern used in addressToken, txStatus, and other views.

Closes #70
2026-02-28 12:11:55 -08: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
24 changed files with 1335 additions and 701 deletions

1
.gitignore vendored
View File

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

View File

@@ -213,22 +213,6 @@ create an address with the same visible characters and trick the user into
sending funds to it. Showing the complete identifier defeats this class of sending funds to it. Showing the complete identifier defeats this class of
attack. attack.
#### Clipboard Policy
AutistMask never clears or overwrites the user's clipboard. When sensitive data
such as a private key is copied, it is the user's responsibility to manage their
clipboard afterwards. We deliberately avoid auto-clearing the clipboard for two
reasons:
1. **User expectations**: silently modifying the clipboard violates the
principle of least surprise. The user initiated the copy and knows the
content is sensitive.
2. **Data safety**: the user may have copied something else important in the
intervening time. A timed clipboard clear would destroy that unrelated data.
The warning shown before revealing a private key makes it clear that the key is
sensitive and that clipboard management is the user's responsibility.
#### Data Model #### Data Model
The core hierarchy is **Wallets → Addresses**: The core hierarchy is **Wallets → Addresses**:
@@ -332,34 +316,15 @@ transitions.
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline). - Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
Each balance row is clickable → **AddressToken** Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons - Send / Receive / + Token buttons
- "Show private key" button
- Transaction list (with ENS resolution for counterparties) - Transaction list (with ENS resolution for counterparties)
- **Transitions**: - **Transitions**:
- Tap balance row → **AddressToken** (for that token) - Tap balance row → **AddressToken** (for that token)
- "Send" → **Send** - "Send" → **Send**
- "Receive" → **Receive** - "Receive" → **Receive**
- "+ Token" → **AddToken** - "+ Token" → **AddToken**
- "Show private key" → **ShowPrivateKey**
- Tap transaction row → **TransactionDetail** - Tap transaction row → **TransactionDetail**
- "Back" → **Home** - "Back" → **Home**
#### ShowPrivateKey
- **When**: User clicked "Show private key" on AddressDetail.
- **Elements**:
- "Back" button
- Title: "Display Private Key"
- Warning box (lock + money icons) explaining the key controls funds and
that the user is responsible for clipboard management
- Password input
- "Display Private Key" button (with lock + money icons)
- After reveal: private key in a read-only well (monospace, select-all),
Copy button, Done button
- **Transitions**:
- "Display Private Key" (correct password) → reveals key in-place
- "Copy" → copies key to clipboard
- "Done" / "Back" → **AddressDetail** (key cleared from DOM)
#### AddressToken #### AddressToken
- **When**: User clicked a specific token balance on AddressDetail. - **When**: User clicked a specific token balance on AddressDetail.

View File

@@ -305,15 +305,26 @@
> >
+ Token + Token
</button> </button>
</div> <div class="relative">
<div class="mb-3">
<button <button
id="btn-show-private-key" id="btn-more-menu"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
aria-label="More actions"
> >
&#128274; Show private key &middot;&middot;&middot;
</button> </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> </div>
<!-- transactions --> <!-- transactions -->
@@ -327,74 +338,57 @@
</div> </div>
</div> </div>
<!-- ============ SHOW PRIVATE KEY ============ --> <!-- ============ EXPORT PRIVATE KEY VIEW ============ -->
<div id="view-show-private-key" class="view hidden"> <div id="view-export-privkey" class="view hidden">
<button <button
id="btn-show-pk-back" id="btn-export-privkey-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
> >
&lt; Back &lt; Back
</button> </button>
<h2 class="font-bold mb-2">Display Private Key</h2>
<!-- password prompt section -->
<div id="show-pk-prompt">
<div <div
class="border border-border border-dashed p-3 mb-3 text-xs" id="export-privkey-jazzicon"
> class="flex justify-center mt-1 mb-3"
<p class="mb-1"> ></div>
&#128274;&#128176; Your private key controls this <h2 class="font-bold mb-1">Export Private Key</h2>
address and all its funds. Anyone who has it can <p class="text-xs mb-1" id="export-privkey-title"></p>
spend your tokens. <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>
<p> <p class="text-xs mb-3 text-muted">
Do not share it. Do not paste it into websites. If Warning: anyone with this private key can access and
you copy it, you are responsible for clearing your transfer all funds from this address. Never share it.
clipboard when you are done.
</p> </p>
</div> <div
<div class="mb-2"> 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> <label class="block mb-1">Password</label>
<input <input
type="password" type="password"
id="show-pk-password" id="export-privkey-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password" 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>
<div id="export-privkey-result" class="hidden">
<div <div
id="show-pk-error" id="export-privkey-value"
class="text-xs mb-2 border border-border border-dashed p-1 hidden" class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div> ></div>
<button
id="btn-show-pk-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
&#128274;&#128176; Display Private Key
</button>
</div>
<!-- revealed key section -->
<div id="show-pk-key-well" class="hidden">
<div
class="bg-well p-3 mx-1 mb-3 break-all font-mono text-xs select-all"
>
<span id="show-pk-key-value"></span>
</div>
<div class="flex gap-2">
<button
id="btn-show-pk-copy"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Copy
</button>
<button
id="btn-show-pk-done"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Done
</button>
</div>
</div> </div>
</div> </div>
@@ -502,6 +496,11 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Address (0x...) or ENS name" placeholder="Address (0x...) or ENS name"
/> />
<div
id="send-to-error"
class="text-xs"
style="min-height: 1.25rem; color: #cc0000"
></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<div class="flex justify-between mb-1"> <div class="flex justify-between mb-1">
@@ -578,15 +577,40 @@
<div id="confirm-fee-amount" class="text-xs"></div> <div id="confirm-fee-amount" class="text-xs"></div>
</div> </div>
<div id="confirm-warnings" class="mb-2 hidden"></div> <div id="confirm-warnings" class="mb-2 hidden"></div>
<div
id="confirm-recipient-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</div>
</div>
<div <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden" class="mb-2 border border-border border-dashed p-2 hidden"
></div> ></div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
type="password"
id="confirm-tx-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
></div>
<button <button
id="btn-confirm-send" id="btn-confirm-send"
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"
> >
Send Sign &amp; Send
</button> </button>
</div> </div>
@@ -611,6 +635,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>
@@ -664,42 +689,6 @@
</button> </button>
</div> </div>
<!-- ============ PASSWORD MODAL ============ -->
<div
id="password-modal"
class="hidden fixed inset-0 bg-bg flex items-center justify-center z-50"
>
<div class="border border-border p-4 bg-bg w-80">
<h2 class="font-bold mb-2">Enter Password</h2>
<p class="text-xs text-muted mb-2">
Your password is needed to authorize this transaction.
</p>
<input
type="password"
id="modal-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg mb-2"
/>
<div
id="modal-password-error"
class="text-xs mb-2 border border-border border-dashed p-1 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-modal-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm
</button>
<button
id="btn-modal-cancel"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
</div>
</div>
<!-- ============ RECEIVE ============ --> <!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden"> <div id="view-receive" class="view hidden">
<button <button
@@ -716,9 +705,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
@@ -1079,7 +1069,12 @@
class="text-xs" class="text-xs"
></div> ></div>
</div> </div>
<div id="tx-detail-rawdata-section" class="hidden"> </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 class="text-xs text-muted mb-1">Raw data</div>
<div <div
id="tx-detail-rawdata" id="tx-detail-rawdata"
@@ -1087,11 +1082,6 @@
></div> ></div>
</div> </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>
<!-- ============ TRANSACTION APPROVAL ============ --> <!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden"> <div id="view-approve-tx" class="view hidden">
@@ -1138,7 +1128,10 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<div id="approve-tx-error" class="text-xs hidden mb-2"></div> <div
id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden"
></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
id="btn-approve-tx" id="btn-approve-tx"
@@ -1201,7 +1194,10 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<div id="approve-sign-error" class="text-xs hidden mb-2"></div> <div
id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden"
></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
id="btn-approve-sign" id="btn-approve-sign"

View File

@@ -19,7 +19,6 @@ const txStatus = require("./views/txStatus");
const transactionDetail = require("./views/transactionDetail"); 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 showPrivateKey = require("./views/showPrivateKey");
const settings = require("./views/settings"); const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken"); const settingsAddToken = require("./views/settingsAddToken");
const approval = require("./views/approval"); const approval = require("./views/approval");
@@ -57,7 +56,6 @@ const ctx = {
showAddWalletView: () => addWallet.show(), showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(), showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(), showAddressDetail: () => addressDetail.show(),
showPrivateKey: () => showPrivateKey.show(),
showAddressToken: () => addressToken.show(), showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(), showAddTokenView: () => addToken.show(),
showConfirmTx: (txInfo) => confirmTx.show(txInfo), showConfirmTx: (txInfo) => confirmTx.show(txInfo),
@@ -76,6 +74,7 @@ const RESTORABLE_VIEWS = new Set([
"receive", "receive",
"settings", "settings",
"settings-addtoken", "settings-addtoken",
"confirm-tx",
"transaction", "transaction",
"success-tx", "success-tx",
"error-tx", "error-tx",
@@ -129,6 +128,13 @@ function restoreView() {
case "settings-addtoken": case "settings-addtoken":
settingsAddToken.show(); settingsAddToken.show();
break; break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView();
}
break;
case "transaction": case "transaction":
if (state.viewData && state.viewData.tx) { if (state.viewData && state.viewData.tx) {
transactionDetail.render(); transactionDetail.render();
@@ -214,7 +220,6 @@ async function init() {
importKey.init(ctx); importKey.init(ctx);
home.init(ctx); home.init(ctx);
addressDetail.init(ctx); addressDetail.init(ctx);
showPrivateKey.init(ctx);
addressToken.init(ctx); addressToken.init(ctx);
send.init(ctx); send.init(ctx);
confirmTx.init(ctx); confirmTx.init(ctx);

View File

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

View File

@@ -15,9 +15,15 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send"); const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
let ctx; let ctx;
@@ -186,8 +192,12 @@ function renderTransactions(txs) {
let html = ""; let html = "";
let i = 0; let i = 0;
for (const tx of txs) { for (const tx of txs) {
// For swap transactions, show the user's own labelled wallet
// address instead of the contract address (see issue #55).
const counterparty = const counterparty =
tx.direction === "sent" || tx.direction === "contract" tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to ? tx.to
: tx.from; : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
@@ -253,6 +263,7 @@ function init(_ctx) {
$("send-token").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
updateSendBalance(); updateSendBalance();
resetSendValidation();
showView("send"); showView("send");
}); });
@@ -262,8 +273,106 @@ function init(_ctx) {
$("btn-add-token").addEventListener("click", ctx.showAddTokenView); $("btn-add-token").addEventListener("click", ctx.showAddTokenView);
$("btn-show-private-key").addEventListener("click", () => { // More menu dropdown
ctx.showPrivateKey(); 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 btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
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");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
});
$("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();
}); });
} }

View File

@@ -12,7 +12,7 @@ const {
balanceLine, balanceLine,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { const {
formatUsd, formatUsd,
getPrice, getPrice,
@@ -23,7 +23,11 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send"); const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
@@ -87,6 +91,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");
@@ -95,7 +100,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);
} }
@@ -138,13 +147,32 @@ function show() {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(), (t) => t.address.toLowerCase() === tokenId.toLowerCase(),
); );
const tokenName = tb && tb.name ? escapeHtml(tb.name) : null; const tracked = (state.trackedTokens || []).find(
const tokenSymbol = tb && tb.symbol ? escapeHtml(tb.symbol) : null; (t) => t.address.toLowerCase() === tokenId.toLowerCase(),
const tokenDecimals = tb && tb.decimals != null ? tb.decimals : null; );
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 tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId); const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`; const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
const projectUrl = knownToken && knownToken.url ? knownToken.url : null; const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`; let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml += infoHtml +=
@@ -348,6 +376,7 @@ function init(_ctx) {
}); });
} }
updateSendBalance(); updateSendBalance();
resetSendValidation();
showView("send"); showView("send");
}); });

View File

@@ -4,6 +4,8 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
showView, showView,
showError,
hideError,
} = require("./helpers"); } = 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");
@@ -78,10 +80,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",
@@ -100,7 +104,11 @@ function decodeCalldata(data, toAddress) {
value: spender, value: spender,
address: spender, address: spender,
}, },
{ label: "Amount", value: amountStr }, {
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
], ],
}; };
} }
@@ -108,9 +116,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",
@@ -125,7 +135,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,
},
], ],
}; };
} }
@@ -158,20 +172,41 @@ function showTxApproval(details) {
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(details.txParams.data, toAddr || ""); const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) { if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
for (const d of decoded.details) { for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) { if (d.label === "Recipient" && d.address) {
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 (d.label === "Token In" && d.isToken && d.address) {
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
if (t) {
decodedTokenAddr = d.address;
decodedTokenSymbol = t.symbol;
}
} }
} }
if (token) { if (token) {
pendingTxDetails.token = toAddr; pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol; pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddr) {
pendingTxDetails.token = decodedTokenAddr;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
} }
} }
// 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);
@@ -233,6 +268,9 @@ function showTxApproval(details) {
$("approve-tx-data-section").classList.add("hidden"); $("approve-tx-data-section").classList.add("hidden");
} }
$("approve-tx-password").value = "";
$("approve-tx-error").classList.add("hidden");
showView("approve-tx"); showView("approve-tx");
} }
@@ -321,7 +359,7 @@ function showSignApproval(details) {
} }
$("approve-sign-password").value = ""; $("approve-sign-password").value = "";
$("approve-sign-error").classList.add("hidden"); hideError("approve-sign-error");
$("btn-approve-sign").disabled = false; $("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted"); $("btn-approve-sign").classList.remove("text-muted");
@@ -386,11 +424,10 @@ function init(ctx) {
$("btn-approve-tx").addEventListener("click", () => { $("btn-approve-tx").addEventListener("click", () => {
const password = $("approve-tx-password").value; const password = $("approve-tx-password").value;
if (!password) { if (!password) {
$("approve-tx-error").textContent = "Please enter your password."; showError("approve-tx-error", "Please enter your password.");
$("approve-tx-error").classList.remove("hidden");
return; return;
} }
$("approve-tx-error").classList.add("hidden"); hideError("approve-tx-error");
$("btn-approve-tx").disabled = true; $("btn-approve-tx").disabled = true;
$("btn-approve-tx").classList.add("text-muted"); $("btn-approve-tx").classList.add("text-muted");
@@ -426,11 +463,10 @@ function init(ctx) {
$("btn-approve-sign").addEventListener("click", () => { $("btn-approve-sign").addEventListener("click", () => {
const password = $("approve-sign-password").value; const password = $("approve-sign-password").value;
if (!password) { if (!password) {
$("approve-sign-error").textContent = "Please enter your password."; showError("approve-sign-error", "Please enter your password.");
$("approve-sign-error").classList.remove("hidden");
return; return;
} }
$("approve-sign-error").classList.add("hidden"); hideError("approve-sign-error");
$("btn-approve-sign").disabled = true; $("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted"); $("btn-approve-sign").classList.add("text-muted");
@@ -448,8 +484,7 @@ function init(ctx) {
} else { } else {
const msg = const msg =
(response && response.error) || "Signing failed."; (response && response.error) || "Signing failed.";
$("approve-sign-error").textContent = msg; showError("approve-sign-error", msg);
$("approve-sign-error").classList.remove("hidden");
$("btn-approve-sign").disabled = false; $("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted"); $("btn-approve-sign").classList.remove("text-muted");
} }

View File

@@ -1,6 +1,6 @@
// Transaction confirmation view + password modal. // Transaction confirmation view with inline password.
// Shows transaction details, warnings, errors. On proceed, opens // Shows transaction details, warnings, errors. On Sign & Send,
// password modal, decrypts secret, signs and broadcasts. // reads inline password, decrypts secret, signs and broadcasts.
const { const {
parseEther, parseEther,
@@ -14,6 +14,7 @@ const {
showError, showError,
hideError, hideError,
showView, showView,
showFlash,
addressTitle, addressTitle,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
@@ -38,6 +39,13 @@ const EXT_ICON =
let pendingTx = null; let pendingTx = null;
function restore() {
const d = state.viewData;
if (d && d.pendingTx) {
show(d.pendingTx);
}
}
function etherscanTokenLink(address) { function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`; return `https://etherscan.io/token/${address}`;
} }
@@ -95,11 +103,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">${dot}` +
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; `<span class="break-all underline decoration-dashed cursor-pointer" 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>`;
tokenSection.classList.remove("hidden"); tokenSection.classList.remove("hidden");
// Attach click-to-copy on the contract address
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
};
}
} else { } else {
tokenSection.classList.add("hidden"); tokenSection.classList.add("hidden");
} }
@@ -214,12 +233,23 @@ function show(txInfo) {
sendBtn.classList.remove("text-muted"); sendBtn.classList.remove("text-muted");
} }
// Reset password field and error
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
// Gas estimate — show placeholder then fetch async // Gas estimate — show placeholder then fetch async
$("confirm-fee").classList.remove("hidden"); $("confirm-fee").classList.remove("hidden");
$("confirm-fee-amount").textContent = "Estimating..."; $("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo };
showView("confirm-tx"); showView("confirm-tx");
// Reset recipient warning: reserve space (visibility:hidden) while
// the async check runs, preventing layout shift per README policy.
const recipientWarning = $("confirm-recipient-warning");
recipientWarning.style.visibility = "hidden";
estimateGas(txInfo); estimateGas(txInfo);
checkRecipientHistory(txInfo);
} }
async function estimateGas(txInfo) { async function estimateGas(txInfo) {
@@ -262,39 +292,44 @@ async function estimateGas(txInfo) {
} }
} }
function showPasswordModal() { async function checkRecipientHistory(txInfo) {
$("modal-password").value = ""; const el = $("confirm-recipient-warning");
hideError("modal-password-error"); try {
$("password-modal").classList.remove("hidden"); const provider = getProvider(state.rpcUrl);
} // Skip warning for contract addresses — they may legitimately
// have zero outgoing transactions (getTransactionCount returns
function hidePasswordModal() { // the nonce, i.e. sent-tx count only).
$("password-modal").classList.add("hidden"); const code = await provider.getCode(txInfo.to);
if (code && code !== "0x") {
// Contract address — no warning needed, keep space reserved
// but invisible to prevent layout shift
return;
}
const txCount = await provider.getTransactionCount(txInfo.to);
if (txCount === 0) {
el.style.visibility = "visible";
}
// If txCount > 0, leave visibility:hidden — space stays reserved
} catch (e) {
log.errorf("recipient history check failed:", e.message);
// On error, leave visibility:hidden — no layout shift, no false warning
}
} }
function init(ctx) { function init(ctx) {
$("btn-confirm-send").addEventListener("click", () => { $("btn-confirm-send").addEventListener("click", async () => {
showPasswordModal(); const password = $("confirm-tx-password").value;
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
$("btn-modal-cancel").addEventListener("click", () => {
hidePasswordModal();
});
$("btn-modal-confirm").addEventListener("click", async () => {
const password = $("modal-password").value;
if (!password) { if (!password) {
showError("modal-password-error", "Please enter your password."); showError(
"confirm-tx-password-error",
"Please enter your password.",
);
return; return;
} }
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
let decryptedSecret; let decryptedSecret;
hideError("modal-password-error"); hideError("confirm-tx-password-error");
try { try {
decryptedSecret = await decryptWithPassword( decryptedSecret = await decryptWithPassword(
@@ -302,11 +337,12 @@ function init(ctx) {
password, password,
); );
} catch (e) { } catch (e) {
showError("modal-password-error", "Wrong password."); showError("confirm-tx-password-error", "Wrong password.");
return; return;
} }
hidePasswordModal(); $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
let tx; let tx;
try { try {
@@ -343,8 +379,15 @@ function init(ctx) {
decryptedSecret = null; 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);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
} }
}); });
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
} }
module.exports = { init, show }; module.exports = { init, show, restore };

View File

@@ -40,6 +40,10 @@ function init(_ctx) {
return; return;
} }
const btn = $("btn-delete-wallet-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const walletIdx = deleteWalletIndex; const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
@@ -49,6 +53,8 @@ function init(_ctx) {
} catch (_e) { } catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password."; $("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden"); $("delete-wallet-flash").classList.remove("hidden");
btn.disabled = false;
btn.classList.remove("text-muted");
return; return;
} }

View File

@@ -16,7 +16,6 @@ const VIEWS = [
"import-key", "import-key",
"main", "main",
"address", "address",
"show-private-key",
"address-token", "address-token",
"send", "send",
"confirm-tx", "confirm-tx",
@@ -32,6 +31,7 @@ const VIEWS = [
"approve-site", "approve-site",
"approve-tx", "approve-tx",
"approve-sign", "approve-sign",
"export-privkey",
]; ];
function $(id) { function $(id) {

View File

@@ -11,7 +11,11 @@ const {
truncateMiddle, 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,
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { const {
formatUsd, formatUsd,
@@ -103,8 +107,13 @@ function renderHomeTxList(ctx) {
let html = ""; let html = "";
let i = 0; let i = 0;
for (const tx of homeTxs) { for (const tx of homeTxs) {
// 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 = const counterparty =
tx.direction === "sent" || tx.direction === "contract" tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to ? tx.to
: tx.from; : tx.from;
const dirLabel = tx.directionLabel; const dirLabel = tx.directionLabel;
@@ -383,6 +392,7 @@ function init(ctx) {
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
updateSendBalance(); updateSendBalance();
resetSendValidation();
showView("send"); showView("send");
}); });

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

@@ -10,7 +10,108 @@ const {
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, resolveSymbol } = require("../../shared/tokenList");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
/**
* Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }.
*/
function validateToAddress(value) {
const v = value.trim();
if (!v) return { valid: false, error: "" };
// ENS names: contains a dot and doesn't start with 0x
if (v.includes(".") && !v.startsWith("0x")) {
// Basic ENS format check: at least one label before and after dot
if (/^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$/.test(v)) {
return { valid: true };
}
return {
valid: false,
error: "Please enter a valid ENS name.",
};
}
// Must look like an Ethereum address
if (!/^0x[0-9a-fA-F]{40}$/.test(v)) {
return {
valid: false,
error: "Please enter a valid Ethereum address.",
};
}
// Reject zero address
if (v.toLowerCase() === ZERO_ADDRESS) {
return {
valid: false,
error: "Sending to the zero address is not allowed.",
};
}
// EIP-55 checksum validation: all-lowercase is ok, otherwise must match checksum
if (v !== v.toLowerCase()) {
try {
const checksummed = getAddress(v);
if (checksummed !== v) {
return {
valid: false,
error: "Address checksum is invalid. Please double-check the address.",
};
}
} catch {
return {
valid: false,
error: "Address checksum is invalid. Please double-check the address.",
};
}
}
// Warn if sending to own address
const addr = currentAddress();
if (addr && v.toLowerCase() === addr.address.toLowerCase()) {
// Allow but will warn — we return valid with a warning
return {
valid: true,
warning: "This is your own address. Are you sure?",
};
}
return { valid: true };
}
function updateToValidation() {
const input = $("send-to");
const errorEl = $("send-to-error");
const btn = $("btn-send-review");
const value = input.value.trim();
if (!value) {
errorEl.textContent = "";
btn.disabled = true;
btn.classList.add("opacity-50");
return;
}
const result = validateToAddress(value);
if (!result.valid) {
errorEl.textContent = result.error;
errorEl.style.color = "#cc0000";
btn.disabled = true;
btn.classList.add("opacity-50");
} else if (result.warning) {
errorEl.textContent = result.warning;
errorEl.style.color = "#b8860b";
btn.disabled = false;
btn.classList.remove("opacity-50");
} else {
errorEl.textContent = "";
btn.disabled = false;
btn.classList.remove("opacity-50");
}
}
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">` +
@@ -73,7 +174,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;
@@ -84,6 +189,13 @@ function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("send-token").addEventListener("change", updateSendBalance); $("send-token").addEventListener("change", updateSendBalance);
// Initial state: disable review button until address is entered
$("btn-send-review").disabled = true;
$("btn-send-review").classList.add("opacity-50");
// Validate address on input
$("send-to").addEventListener("input", updateToValidation);
$("btn-send-review").addEventListener("click", async () => { $("btn-send-review").addEventListener("click", async () => {
const to = $("send-to").value.trim(); const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim(); const amount = $("send-amount").value.trim();
@@ -91,6 +203,15 @@ function init(_ctx) {
showFlash("Please enter a recipient address."); showFlash("Please enter a recipient address.");
return; return;
} }
// Re-validate before proceeding
const validation = validateToAddress(to);
if (!validation.valid) {
showFlash(
validation.error || "Please enter a valid Ethereum address.",
);
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showFlash("Please enter a valid amount."); showFlash("Please enter a valid amount.");
return; return;
@@ -124,7 +245,11 @@ function init(_ctx) {
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";
} }
@@ -151,4 +276,19 @@ function init(_ctx) {
}); });
} }
module.exports = { init, updateSendBalance, renderSendTokenSelect }; function resetSendValidation() {
const errorEl = $("send-to-error");
const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = "";
if (btn) {
btn.disabled = true;
btn.classList.add("opacity-50");
}
}
module.exports = {
init,
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
};

View File

@@ -1,79 +0,0 @@
const { $, showView, showFlash, showError, hideError } = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { getPrivateKeyForAddress } = require("../../shared/wallet");
let ctx;
let revealed = false;
function show() {
revealed = false;
$("show-pk-password").value = "";
$("show-pk-key-well").classList.add("hidden");
$("show-pk-key-value").textContent = "";
$("show-pk-prompt").classList.remove("hidden");
hideError("show-pk-error");
showView("show-private-key");
}
function init(_ctx) {
ctx = _ctx;
$("btn-show-pk-back").addEventListener("click", () => {
clearKey();
ctx.showAddressDetail();
});
$("btn-show-pk-reveal").addEventListener("click", async () => {
const pw = $("show-pk-password").value;
if (!pw) {
showError("show-pk-error", "Please enter your password.");
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
wallet.encryptedSecret,
pw,
);
} catch (_e) {
showError("show-pk-error", "Wrong password.");
return;
}
const privateKey = getPrivateKeyForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
revealed = true;
$("show-pk-prompt").classList.add("hidden");
$("show-pk-key-well").classList.remove("hidden");
$("show-pk-key-value").textContent = privateKey;
hideError("show-pk-error");
});
$("btn-show-pk-copy").addEventListener("click", () => {
const key = $("show-pk-key-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
}
});
$("btn-show-pk-done").addEventListener("click", () => {
clearKey();
ctx.showAddressDetail();
});
}
function clearKey() {
revealed = false;
$("show-pk-key-value").textContent = "";
$("show-pk-password").value = "";
}
module.exports = { init, show };

View File

@@ -37,11 +37,19 @@ 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="font-bold">${escapeHtml(title)}</div>`; html += `<div class="font-bold">${escapeHtml(title)}</div>`;
@@ -50,10 +58,10 @@ function txAddressHtml(address, ensName, title) {
html += html +=
`<div class="flex items-center">${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 +=
@@ -67,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;
} }
@@ -93,9 +101,6 @@ function show(tx) {
}, },
}; };
render(); render();
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
} }
function render() { function render() {
@@ -138,18 +143,24 @@ function render() {
typeEl.textContent = tx.directionLabel; typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden"); typeSection.classList.remove("hidden");
} }
if (headingEl) headingEl.textContent = tx.directionLabel;
} else { } else {
if (typeSection) typeSection.classList.add("hidden"); if (typeSection) typeSection.classList.add("hidden");
if (headingEl) headingEl.textContent = "Transaction";
} }
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata section by default; loadCalldata will show it if needed // Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section"); const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden"); if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
$("tx-detail-time").textContent = if (tx.isContractCall || tx.direction === "contract") {
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; loadCalldata(tx.hash, tx.to);
}
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
@@ -193,9 +204,20 @@ async function loadCalldata(txHash, toAddress) {
for (const d of decoded.details || []) { for (const d of decoded.details || []) {
detailsHtml += `<div class="mb-2">`; detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then dot + address + Etherscan link
const dot = addressDotHtml(d.address); const dot = addressDotHtml(d.address);
detailsHtml += `<div>${dot}${copyableHtml(d.value, "break-all")}</div>`; 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 { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
@@ -219,13 +241,16 @@ async function loadCalldata(txHash, toAddress) {
section.classList.remove("hidden"); section.classList.remove("hidden");
// Bind copy handlers for new elements // Bind copy handlers for new elements (including raw data now outside section)
section.querySelectorAll("[data-copy]").forEach((el) => { const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) {
container.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
}; };
}); });
}
} catch (e) { } catch (e) {
log.errorf("loadCalldata failed:", e.message); log.errorf("loadCalldata failed:", e.message);
} }

View File

@@ -8,6 +8,7 @@ const {
addressTitle, 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");
@@ -42,10 +43,11 @@ function toAddressHtml(address) {
if (title) { if (title) {
return ( return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` + `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all">${escapeHtml(address)}${extLink}</div>` `<div class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` +
extLink
); );
} }
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 underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`;
} }
function txHashHtml(hash) { function txHashHtml(hash) {
@@ -57,6 +59,16 @@ function txHashHtml(hash) {
); );
} }
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = `https://etherscan.io/block/${num}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
extLink
);
}
function attachCopyHandlers(viewId) { function attachCopyHandlers(viewId) {
document document
.getElementById(viewId) .getElementById(viewId)
@@ -121,18 +133,84 @@ 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 = `<div class="border border-border border-dashed p-2 mb-3">`;
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>`;
}
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;
const hasDecoded = d.decoded && d.decoded.details;
// When decoded details are present, the Amount and To are already
// shown inside the decoded well — hide the top-level duplicates.
const summarySection = $("success-tx-summary").parentElement;
const toSection = $("success-tx-to").parentElement;
if (hasDecoded) {
summarySection.classList.add("hidden");
toSection.classList.add("hidden");
} else {
summarySection.classList.remove("hidden");
toSection.classList.remove("hidden");
$("success-tx-summary").textContent = d.amount + " " + d.symbol; $("success-tx-summary").textContent = d.amount + " " + d.symbol;
$("success-tx-to").innerHTML = toAddressHtml(d.to); $("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber); }
$("success-tx-block").innerHTML = blockNumberHtml(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 && hasDecoded) {
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,

View File

@@ -3645,10 +3645,27 @@ async function getTopTokenPrices(n) {
return prices; 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 = { module.exports = {
TOKENS, TOKENS,
TOKEN_BY_ADDRESS, TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS, KNOWN_SYMBOLS,
getTopTokens, getTopTokens,
getTopTokenPrices, getTopTokenPrices,
resolveSymbol,
}; };

View File

@@ -153,9 +153,11 @@ 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. A single transaction (e.g. a swap) can produce
// but preserve contract call metadata (direction, label, method) so // multiple token transfers (one per token involved), so we key token
// swaps and other contract interactions display correctly. // 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 || []) { for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower); const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash); const existing = txsByHash.get(parsed.hash);
@@ -164,8 +166,13 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
parsed.directionLabel = existing.directionLabel; parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true; parsed.isContractCall = true;
parsed.method = existing.method; 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);
} }
txsByHash.set(parsed.hash, parsed); // 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()]; 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. // 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;
} }
@@ -281,12 +445,18 @@ function decode(data, toAddress) {
const maxUint160 = BigInt( const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff", "0xffffffffffffffffffffffffffffffffffffffff",
); );
const amountStr = const isUnlimited = inputAmount >= maxUint160;
inputAmount >= maxUint160 const amountRaw = isUnlimited
? "Unlimited" ? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals) + : formatAmount(inputAmount, inInfo.decimals);
(inSymbol ? " " + inSymbol : ""); const amountStr = isUnlimited
details.push({ label: "Amount", value: amountStr }); ? "Unlimited"
: amountRaw + (inSymbol ? " " + inSymbol : "");
details.push({
label: "Amount",
value: amountStr,
rawValue: amountRaw,
});
} }
if (outSymbol) { if (outSymbol) {

View File

@@ -41,18 +41,6 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
return new Wallet(decryptedSecret); return new Wallet(decryptedSecret);
} }
function getPrivateKeyForAddress(walletData, addrIndex, decryptedSecret) {
if (walletData.type === "hd") {
const node = HDNodeWallet.fromPhrase(
decryptedSecret,
"",
BIP44_ETH_PATH,
);
return node.deriveChild(addrIndex).privateKey;
}
return decryptedSecret;
}
function isValidMnemonic(mnemonic) { function isValidMnemonic(mnemonic) {
return Mnemonic.isValidMnemonic(mnemonic); return Mnemonic.isValidMnemonic(mnemonic);
} }
@@ -63,6 +51,5 @@ module.exports = {
hdWalletFromMnemonic, hdWalletFromMnemonic,
addressFromPrivateKey, addressFromPrivateKey,
getSignerForAddress, getSignerForAddress,
getPrivateKeyForAddress,
isValidMnemonic, 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