Compare commits

..

70 Commits

Author SHA1 Message Date
user
cd412209a7 fix: cross-wallet duplicate address detection on import
All checks were successful
check / check (push) Successful in 22s
Previously each import method (mnemonic, private key, xprv) only checked
for duplicates among wallets of the same type. Now all three check the
derived address against ALL existing wallet addresses regardless of type,
and mnemonic/xprv imports also compare xpubs against existing HD/xprv
wallets.

Closes #111
2026-02-28 16:01:40 -08:00
09c52b2519 Merge pull request 'feat: show red warning when sending to address with zero tx history' (#98) from issue-82-zero-tx-warning into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #98
2026-03-01 00:54:15 +01:00
1fb9fade51 Merge branch 'main' into issue-82-zero-tx-warning
All checks were successful
check / check (push) Successful in 22s
2026-03-01 00:53:45 +01:00
bc04482fb5 Merge pull request 'feat: add xprv wallet import support' (#53) from feature/import-xprv into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #53
2026-03-01 00:53:08 +01:00
user
045328f3b9 fix: use visibility:hidden/visible instead of CSS transitions for zero-tx warning
All checks were successful
check / check (push) Successful in 22s
Remove all CSS transitions, max-height changes, and opacity animations.
The warning container always reserves its space with visibility:hidden
and switches to visibility:visible when needed. No layout shift ever.
2026-02-28 15:46:58 -08:00
user
576fe3ab15 fix: replace visibility:hidden with smooth collapse for zero-tx warning
All checks were successful
check / check (push) Successful in 10s
Instead of permanently reserving space with visibility:hidden, the warning
container now uses max-height + opacity transitions. Space is reserved during
the async check, then smoothly collapses to 0 if the warning isn't needed.
This reclaims ~40px of popup viewport in the common case.
2026-02-28 15:37:27 -08:00
user
35bb6b9806 fix: add hover classes to all inactive tabs in switchMode()
All checks were successful
check / check (push) Successful in 22s
The 'From Phrase' tab was missing hover:bg-fg and hover:text-bg classes
when transitioning from active to inactive state. switchMode() now
explicitly toggles these hover classes on all tabs, ensuring identical
hover behavior across all three inactive tabs.
2026-02-28 15:33:26 -08:00
user
e56e15e34c style: tabby tab styling with dashed inactive borders and hover invert
- Active tab: solid border on top/sides, bottom border matches background
  (connects to content area), bold text
- Inactive tabs: dashed borders in border-light color, muted text,
  transparent bottom border
- Inactive hover: invert (bg-fg text-bg) for clear clickability signal
- All three tabs behave identically on hover
2026-02-28 15:33:26 -08:00
user
cc69ce39ed fix tabs: use underline tab style with hover on all tabs
Tabs are not buttons (they change UI state, not application state).
All tabs now use underline style with identical hover behavior:
- Active: bold text + solid bottom border
- Inactive: muted text + transparent bottom border
- Hover (all tabs): text brightens to fg + bottom border appears
This ensures all tabs clearly indicate clickability on hover,
including the currently active one.
2026-02-28 15:33:26 -08:00
user
9476724284 fix tab affordance: use standard button style with border + hover invert
Per README clickable affordance policy: all tabs now use visible
border, padding, and hover:bg-fg hover:text-bg (invert to
white-on-black). Active tab is inverted (bg-fg text-bg). All
three tabs behave identically on hover regardless of active state.
2026-02-28 15:33:26 -08:00
user
9246959777 fix: tab labels add (xprv) suffix, restyle tabs as underline view switcher
- Rename 'From Extended Key' to 'From Extended Key (xprv)'
- Replace box-border tab style with underline indicator pattern
- Active tab: bold text + solid bottom border
- Inactive tabs: muted text + transparent bottom border with hover state
- Tabs now clearly read as mutually-exclusive view switchers, not buttons
2026-02-28 15:33:26 -08:00
user
0f6daf3200 restyle tabs as classic tab bar with connected active tab 2026-02-28 15:33:26 -08:00
user
435669b6b6 fix: use full tab labels and add hover border for tab affordance
- 'From Phrase' → 'From Recovery Phrase'
- 'From Key' → 'From Private Key'
- 'From xprv' → 'From Extended Key'
- Add hover:border-fg to inactive tabs for visible hover affordance
2026-02-28 15:33:26 -08:00
user
f75a258125 restyle add-wallet tabs: 'From' prefix, underline tab style
- Tab labels: 'From Phrase', 'From Key', 'From xprv'
- Visual: bottom-border underline on active tab (not filled buttons)
- Inactive tabs: muted text with hover highlight
- Container: bottom border connects tabs to content area
2026-02-28 15:33:26 -08:00
user
4d120e5ea9 refactor: unify add-wallet, import-key, and import-xprv into single view
Merge all three wallet import methods (recovery phrase, private key,
extended key/xprv) into one tabbed add-wallet view with a mode selector.
This fixes the blank import-xprv render (it was missing from the VIEWS
array) and the broken back-button navigation from the separate import
views.

- Add tab selector: Recovery Phrase | Private Key | Extended Key (xprv)
- Share password fields across all modes
- Remove separate import-key and import-xprv views and modules
- Add duplicate wallet detection for private key imports
- All tabs follow affordance policy (visible border + hover state)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:33:26 -08:00
user
57959b70c3 fix: derive xprv addresses from correct BIP44 path (m/44'/60'/0'/0)
hdWalletFromXprv() and getSignerForAddress() for xprv type were deriving
addresses directly from the root key (m/N) instead of the standard BIP44
Ethereum path (m/44'/60'/0'/0/N). This caused imported xprv wallets to
generate completely wrong addresses.

Navigate to the BIP44 Ethereum derivation path before deriving child
addresses, matching the behavior of mnemonic-based wallet imports.
2026-02-28 15:33:26 -08:00
user
7a7f9c5135 feat: add xprv wallet import support
Add the ability to import an existing HD wallet using an extended
private key (xprv) instead of a mnemonic phrase.

- New 'xprv' wallet type with full HD derivation and address scanning
- New importXprv view with password encryption
- Updated getSignerForAddress to handle xprv wallet type
- Added xprv link to the add-wallet view
- Allow adding derived addresses for xprv wallets

Closes #20
2026-02-28 15:33:26 -08:00
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
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
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
20 changed files with 1010 additions and 551 deletions

View File

@@ -56,37 +56,104 @@
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Wallet</h2>
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the
button to roll the die for a new one.
</p>
<div class="mb-1 flex justify-end">
<!-- Mode selector tabs -->
<div
class="flex border-b border-border mb-3"
id="add-wallet-tabs"
>
<button
id="btn-generate-phrase"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
title="Generate a random recovery phrase"
id="tab-mnemonic"
class="px-3 py-1.5 cursor-pointer text-xs font-bold border border-border border-b-bg bg-bg -mb-px"
>
[&#9856;]
From Phrase
</button>
<button
id="tab-privkey"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From Key
</button>
<button
id="tab-xprv"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From xprv
</button>
</div>
<div class="mb-2">
<textarea
id="wallet-mnemonic"
rows="3"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg resize-y"
placeholder="word word word ..."
></textarea>
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click
the button to roll the die for a new one.
</p>
<div class="mb-1 flex justify-end">
<button
id="btn-generate-phrase"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
title="Generate a random recovery phrase"
>
[&#9856;]
</button>
</div>
<div class="mb-2">
<textarea
id="wallet-mnemonic"
rows="3"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg resize-y"
placeholder="word word word ..."
></textarea>
</div>
<div
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
>
Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet
is gone.
</div>
</div>
<div
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
>
Write these words down and keep them safe. Anyone with them
can take your funds; if you lose them, your wallet is gone.
<!-- Private key form section -->
<div id="add-wallet-section-privkey" class="hidden">
<p class="mb-2">
Paste your private key below. This wallet will have a
single address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
</div>
<!-- Extended key (xprv) form section -->
<div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses.
</p>
<div class="mb-2">
<input
type="password"
id="import-xprv-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="xprv..."
/>
</div>
</div>
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
<p
class="text-xs text-muted mb-1"
id="add-wallet-password-hint"
>
This password encrypts your recovery phrase on this
device. You will need it to send funds.
</p>
@@ -107,122 +174,6 @@
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
id="btn-add-wallet-import-key"
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
>
Import private key
</button>
</div>
<div class="mt-1 text-xs text-muted">
Have an extended private key (xprv)?
<button
id="btn-add-wallet-import-xprv"
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
>
Import xprv
</button>
</div>
</div>
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
<div class="mb-2" id="import-key-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your private key on this device.
You will need it to send funds.
</p>
<input
type="password"
id="import-key-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-key-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-key-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
</div>
<!-- ============ IMPORT XPRV ============ -->
<div id="view-import-xprv" class="view hidden">
<button
id="btn-import-xprv-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Extended Private Key</h2>
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses.
</p>
<div class="mb-2">
<input
type="password"
id="import-xprv-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="xprv..."
/>
</div>
<div class="mb-2" id="import-xprv-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your key on this device. You will
need it to send funds.
</p>
<input
type="password"
id="import-xprv-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-xprv-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-xprv-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-import-xprv-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
@@ -363,6 +314,26 @@
>
+ Token
</button>
<div class="relative">
<button
id="btn-more-menu"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
aria-label="More actions"
>
&middot;&middot;&middot;
</button>
<div
id="more-menu-dropdown"
class="hidden absolute right-0 top-full mt-1 border border-border bg-bg z-50 whitespace-nowrap py-1"
>
<button
id="btn-export-privkey"
class="block w-full text-left px-4 py-1.5 text-xs font-light text-muted hover:bg-hover hover:text-fg cursor-pointer"
>
Export Private Key
</button>
</div>
</div>
</div>
<!-- transactions -->
@@ -376,6 +347,60 @@
</div>
</div>
<!-- ============ EXPORT PRIVATE KEY VIEW ============ -->
<div id="view-export-privkey" class="view hidden">
<button
id="btn-export-privkey-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
id="export-privkey-jazzicon"
class="flex justify-center mt-1 mb-3"
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<p class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 hidden"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="export-privkey-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="export-privkey-result" class="hidden">
<div
id="export-privkey-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ ADDRESS-TOKEN DETAIL VIEW ============ -->
<div id="view-address-token" class="view hidden">
<button
@@ -480,6 +505,11 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Address (0x...) or ENS name"
/>
<div
id="send-to-error"
class="text-xs"
style="min-height: 1.25rem; color: #cc0000"
></div>
</div>
<div class="mb-2">
<div class="flex justify-between mb-1">
@@ -556,15 +586,40 @@
<div id="confirm-fee-amount" class="text-xs"></div>
</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
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden"
></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
id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Send
Sign &amp; Send
</button>
</div>
@@ -589,6 +644,7 @@
<!-- ============ TX SUCCESS ============ -->
<div id="view-success-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Confirmed</h2>
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div>
@@ -642,42 +698,6 @@
</button>
</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 ============ -->
<div id="view-receive" class="view hidden">
<button
@@ -694,9 +714,10 @@
<div class="flex justify-center mb-3">
<canvas id="receive-qr"></canvas>
</div>
<div class="border border-border p-2 break-all mb-3 text-xs">
<span id="receive-dot"></span>
<span id="receive-address" class="select-all"></span>
<div
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
>
<span id="receive-address-block" class="select-all"></span>
<span id="receive-etherscan-link"></span>
</div>
<button
@@ -1116,7 +1137,10 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</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">
<button
id="btn-approve-tx"
@@ -1179,7 +1203,10 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</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">
<button
id="btn-approve-sign"

View File

@@ -10,8 +10,6 @@ const { $, showView } = require("./views/helpers");
const home = require("./views/home");
const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const importXprv = require("./views/importXprv");
const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
@@ -55,8 +53,6 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showImportXprvView: () => importXprv.show(),
showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(),
@@ -76,6 +72,7 @@ const RESTORABLE_VIEWS = new Set([
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
@@ -129,6 +126,13 @@ function restoreView() {
case "settings-addtoken":
settingsAddToken.show();
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView();
}
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
@@ -211,8 +215,6 @@ async function init() {
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
importXprv.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);

View File

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

View File

@@ -3,114 +3,296 @@ const {
generateMnemonic,
hdWalletFromMnemonic,
isValidMnemonic,
addressFromPrivateKey,
hdWalletFromXprv,
isValidXprv,
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"];
const PASSWORD_HINTS = {
mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds.",
privkey:
"This password encrypts your private key on this device. You will need it to send funds.",
xprv: "This password encrypts your key on this device. You will need it to send funds.",
};
function switchMode(mode) {
currentMode = mode;
for (const m of MODES) {
$("add-wallet-section-" + m).classList.toggle("hidden", m !== mode);
const tab = $("tab-" + m);
const isActive = m === mode;
// Active: bold, solid border on top/sides, no bottom border (connects to content)
tab.classList.toggle("font-bold", isActive);
tab.classList.toggle("border-solid", isActive);
tab.classList.toggle("border-border", isActive);
tab.classList.toggle("border-b-bg", isActive);
tab.classList.toggle("bg-bg", isActive);
// Inactive: muted text, dashed border on top/sides, transparent bottom, hover invert
tab.classList.toggle("text-muted", !isActive);
tab.classList.toggle("border-dashed", !isActive);
tab.classList.toggle("border-border-light", !isActive);
tab.classList.toggle("border-b-transparent", !isActive);
tab.classList.toggle("hover:bg-fg", !isActive);
tab.classList.toggle("hover:text-bg", !isActive);
}
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
}
function show() {
$("wallet-mnemonic").value = "";
$("import-private-key").value = "";
$("import-xprv-key").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
switchMode("mnemonic");
showView("add-wallet");
}
function validatePassword() {
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return null;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return null;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return null;
}
return pw;
}
async function importMnemonic(ctx) {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showFlash("Enter a recovery phrase or press the die to generate one.");
return;
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showFlash(
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
);
return;
}
if (!isValidMnemonic(mnemonic)) {
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = validatePassword();
if (!pw) return;
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const addrDup = state.wallets.find((w) =>
w.addresses.some(
(a) => a.address.toLowerCase() === firstAddress.toLowerCase(),
),
);
if (addrDup) {
showFlash(
"An address from this phrase already exists in " +
addrDup.name +
".",
);
return;
}
const xpubDup = state.wallets.find(
(w) => (w.type === "hd" || w.type === "xprv") && w.xpub === xpub,
);
if (xpubDup) {
showFlash("This recovery phrase matches " + xpubDup.name + ".");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
}
async function importPrivateKey(ctx) {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showFlash("Invalid private key.");
return;
}
const pw = validatePassword();
if (!pw) return;
const duplicate = state.wallets.find((w) =>
w.addresses.some((a) => a.address.toLowerCase() === addr.toLowerCase()),
);
if (duplicate) {
showFlash("This address already exists in " + duplicate.name + ".");
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
}
async function importXprvKey(ctx) {
const xprv = $("import-xprv-key").value.trim();
if (!xprv) {
showFlash("Please enter your extended private key.");
return;
}
if (!isValidXprv(xprv)) {
showFlash("Invalid extended private key.");
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch (e) {
showFlash("Invalid extended private key.");
return;
}
const { xpub, firstAddress } = result;
const addrDup = state.wallets.find((w) =>
w.addresses.some(
(a) => a.address.toLowerCase() === firstAddress.toLowerCase(),
),
);
if (addrDup) {
showFlash(
"An address from this key already exists in " + addrDup.name + ".",
);
return;
}
const xpubDup = state.wallets.find(
(w) => (w.type === "hd" || w.type === "xprv") && w.xpub === xpub,
);
if (xpubDup) {
showFlash("This key matches " + xpubDup.name + ".");
return;
}
const pw = validatePassword();
if (!pw) return;
const encrypted = await encryptWithPassword(xprv, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "xprv",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
}
function init(ctx) {
// Tab click handlers
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
$("tab-xprv").addEventListener("click", () => switchMode("xprv"));
// Generate mnemonic
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden");
});
// Import / confirm
$("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showFlash(
"Enter a recovery phrase or press the die to generate one.",
);
return;
if (currentMode === "mnemonic") {
await importMnemonic(ctx);
} else if (currentMode === "privkey") {
await importPrivateKey(ctx);
} else if (currentMode === "xprv") {
await importXprvKey(ctx);
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showFlash(
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
);
return;
}
if (!isValidMnemonic(mnemonic)) {
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",
);
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
});
// Back button
$("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
@@ -119,16 +301,6 @@ function init(ctx) {
showView("main");
}
});
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
$("btn-add-wallet-import-xprv").addEventListener(
"click",
ctx.showImportXprvView,
);
}
module.exports = { init, show };

View File

@@ -15,9 +15,15 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
let ctx;
@@ -257,6 +263,7 @@ function init(_ctx) {
$("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
updateSendBalance();
resetSendValidation();
showView("send");
});
@@ -265,6 +272,108 @@ function init(_ctx) {
});
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
// More menu dropdown
const moreBtn = $("btn-more-menu");
const moreDropdown = $("more-menu-dropdown");
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = !moreDropdown.classList.toggle("hidden");
moreBtn.classList.toggle("bg-fg", isOpen);
moreBtn.classList.toggle("text-bg", isOpen);
});
document.addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
});
moreDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
$("export-privkey-dot").innerHTML = addressDotHtml(addr.address);
$("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = "";
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
});
$("btn-export-privkey-confirm").addEventListener("click", async () => {
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").classList.remove("hidden");
return;
}
const 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();
});
}
module.exports = { init, show };

View File

@@ -12,7 +12,7 @@ const {
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
@@ -23,7 +23,11 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
@@ -96,14 +100,11 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
symbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
"?";
amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol);
}
@@ -375,6 +376,7 @@ function init(_ctx) {
});
}
updateSendBalance();
resetSendValidation();
showView("send");
});

View File

@@ -4,6 +4,8 @@ const {
addressTitle,
escapeHtml,
showView,
showError,
hideError,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
@@ -170,6 +172,8 @@ function showTxApproval(details) {
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
@@ -177,13 +181,32 @@ function showTxApproval(details) {
if (d.label === "Amount") {
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) {
pendingTxDetails.token = toAddr;
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-from").innerHTML = approvalAddressHtml(state.activeAddress);
@@ -245,6 +268,9 @@ function showTxApproval(details) {
$("approve-tx-data-section").classList.add("hidden");
}
$("approve-tx-password").value = "";
$("approve-tx-error").classList.add("hidden");
showView("approve-tx");
}
@@ -333,7 +359,7 @@ function showSignApproval(details) {
}
$("approve-sign-password").value = "";
$("approve-sign-error").classList.add("hidden");
hideError("approve-sign-error");
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
@@ -398,11 +424,10 @@ function init(ctx) {
$("btn-approve-tx").addEventListener("click", () => {
const password = $("approve-tx-password").value;
if (!password) {
$("approve-tx-error").textContent = "Please enter your password.";
$("approve-tx-error").classList.remove("hidden");
showError("approve-tx-error", "Please enter your password.");
return;
}
$("approve-tx-error").classList.add("hidden");
hideError("approve-tx-error");
$("btn-approve-tx").disabled = true;
$("btn-approve-tx").classList.add("text-muted");
@@ -438,11 +463,10 @@ function init(ctx) {
$("btn-approve-sign").addEventListener("click", () => {
const password = $("approve-sign-password").value;
if (!password) {
$("approve-sign-error").textContent = "Please enter your password.";
$("approve-sign-error").classList.remove("hidden");
showError("approve-sign-error", "Please enter your password.");
return;
}
$("approve-sign-error").classList.add("hidden");
hideError("approve-sign-error");
$("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted");
@@ -460,8 +484,7 @@ function init(ctx) {
} else {
const msg =
(response && response.error) || "Signing failed.";
$("approve-sign-error").textContent = msg;
$("approve-sign-error").classList.remove("hidden");
showError("approve-sign-error", msg);
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
}

View File

@@ -1,6 +1,6 @@
// Transaction confirmation view + password modal.
// Shows transaction details, warnings, errors. On proceed, opens
// password modal, decrypts secret, signs and broadcasts.
// Transaction confirmation view with inline password.
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const {
parseEther,
@@ -14,6 +14,7 @@ const {
showError,
hideError,
showView,
showFlash,
addressTitle,
addressDotHtml,
escapeHtml,
@@ -38,6 +39,13 @@ const EXT_ICON =
let pendingTx = null;
function restore() {
const d = state.viewData;
if (d && d.pendingTx) {
show(d.pendingTx);
}
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
@@ -95,11 +103,22 @@ function show(txInfo) {
// Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section");
if (isErc20) {
const dot = addressDotHtml(txInfo.token);
const link = etherscanTokenLink(txInfo.token);
$("confirm-token-contract").innerHTML =
escapeHtml(txInfo.token) +
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
`<div class="flex items-center">${dot}` +
`<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");
// 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 {
tokenSection.classList.add("hidden");
}
@@ -214,12 +233,21 @@ function show(txInfo) {
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
$("confirm-fee").classList.remove("hidden");
$("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");
// Reset recipient warning to hidden (space always reserved, no layout shift)
$("confirm-recipient-warning").style.visibility = "hidden";
estimateGas(txInfo);
checkRecipientHistory(txInfo);
}
async function estimateGas(txInfo) {
@@ -262,39 +290,40 @@ async function estimateGas(txInfo) {
}
}
function showPasswordModal() {
$("modal-password").value = "";
hideError("modal-password-error");
$("password-modal").classList.remove("hidden");
}
function hidePasswordModal() {
$("password-modal").classList.add("hidden");
async function checkRecipientHistory(txInfo) {
const el = $("confirm-recipient-warning");
try {
const provider = getProvider(state.rpcUrl);
// Skip warning for contract addresses — they may legitimately
// have zero outgoing transactions (getTransactionCount returns
// the nonce, i.e. sent-tx count only).
const code = await provider.getCode(txInfo.to);
if (code && code !== "0x") {
return;
}
const txCount = await provider.getTransactionCount(txInfo.to);
if (txCount === 0) {
el.style.visibility = "visible";
}
} catch (e) {
log.errorf("recipient history check failed:", e.message);
}
}
function init(ctx) {
$("btn-confirm-send").addEventListener("click", () => {
showPasswordModal();
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
$("btn-modal-cancel").addEventListener("click", () => {
hidePasswordModal();
});
$("btn-modal-confirm").addEventListener("click", async () => {
const password = $("modal-password").value;
$("btn-confirm-send").addEventListener("click", async () => {
const password = $("confirm-tx-password").value;
if (!password) {
showError("modal-password-error", "Please enter your password.");
showError(
"confirm-tx-password-error",
"Please enter your password.",
);
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
hideError("modal-password-error");
hideError("confirm-tx-password-error");
try {
decryptedSecret = await decryptWithPassword(
@@ -302,11 +331,12 @@ function init(ctx) {
password,
);
} catch (e) {
showError("modal-password-error", "Wrong password.");
showError("confirm-tx-password-error", "Wrong password.");
return;
}
hidePasswordModal();
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
let tx;
try {
@@ -343,8 +373,15 @@ function init(ctx) {
decryptedSecret = null;
const hash = tx ? tx.hash : null;
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;
}
const btn = $("btn-delete-wallet-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
@@ -49,6 +53,8 @@ function init(_ctx) {
} catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden");
btn.disabled = false;
btn.classList.remove("text-muted");
return;
}

View File

@@ -13,7 +13,6 @@ const { state, saveState } = require("../../shared/state");
const VIEWS = [
"welcome",
"add-wallet",
"import-key",
"main",
"address",
"address-token",
@@ -31,6 +30,7 @@ const VIEWS = [
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
];
function $(id) {

View File

@@ -11,7 +11,11 @@ const {
truncateMiddle,
} = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const {
formatUsd,
@@ -388,6 +392,7 @@ function init(ctx) {
$("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr);
updateSendBalance();
resetSendValidation();
showView("send");
});

View File

@@ -1,69 +0,0 @@
const { $, showView, showFlash } = require("./helpers");
const { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
function show() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
showView("import-key");
}
function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showFlash("Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [
{ address: addr, balance: "0.0000", tokenBalances: [] },
],
});
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
});
$("btn-import-key-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
} else {
ctx.renderWalletList();
showView("main");
}
});
}
module.exports = { init, show };

View File

@@ -1,106 +0,0 @@
const { $, showView, showFlash } = require("./helpers");
const { hdWalletFromXprv, isValidXprv } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
function show() {
$("import-xprv-key").value = "";
$("import-xprv-password").value = "";
$("import-xprv-password-confirm").value = "";
showView("import-xprv");
}
function init(ctx) {
$("btn-import-xprv-confirm").addEventListener("click", async () => {
const xprv = $("import-xprv-key").value.trim();
if (!xprv) {
showFlash("Please enter your extended private key.");
return;
}
if (!isValidXprv(xprv)) {
showFlash("Invalid extended private key.");
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch (e) {
showFlash("Invalid extended private key.");
return;
}
const { xpub, firstAddress } = result;
const duplicate = state.wallets.find(
(w) =>
(w.type === "hd" || w.type === "xprv") &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash("This key is already added (" + duplicate.name + ").");
return;
}
const pw = $("import-xprv-password").value;
const pw2 = $("import-xprv-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const encrypted = await encryptWithPassword(xprv, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "xprv",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
});
$("btn-import-xprv-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
} else {
ctx.renderWalletList();
showView("main");
}
});
}
module.exports = { init, show };

View File

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

View File

@@ -10,7 +10,108 @@ const {
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = 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 =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -73,15 +174,11 @@ function updateSendBalance() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const knownToken = TOKEN_BY_ADDRESS.get(token.toLowerCase());
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
const symbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
"?";
const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
@@ -92,6 +189,13 @@ function init(_ctx) {
ctx = _ctx;
$("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 () => {
const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim();
@@ -99,6 +203,15 @@ function init(_ctx) {
showFlash("Please enter a recipient address.");
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) {
showFlash("Please enter a valid amount.");
return;
@@ -132,15 +245,11 @@ function init(_ctx) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const knownTk = TOKEN_BY_ADDRESS.get(token.toLowerCase());
const trackedTk = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
tokenSymbol =
(tb && tb.symbol) ||
(trackedTk && trackedTk.symbol) ||
(knownTk && knownTk.symbol) ||
"?";
tokenBalance = tb ? tb.balance || "0" : "0";
}
@@ -167,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

@@ -143,11 +143,10 @@ function render() {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
if (headingEl) headingEl.textContent = tx.directionLabel;
} else {
if (typeSection) typeSection.classList.add("hidden");
if (headingEl) headingEl.textContent = "Transaction";
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");
@@ -159,8 +158,9 @@ function render() {
loadCalldata(tx.hash, tx.to);
}
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction");

View File

@@ -8,6 +8,7 @@ const {
addressTitle,
escapeHtml,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -42,10 +43,11 @@ function toAddressHtml(address) {
if (title) {
return (
`<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) {
@@ -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) {
document
.getElementById(viewId)
@@ -121,18 +133,84 @@ function showSuccess(txInfo, txHash, blockNumber) {
to: txInfo.to,
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = `<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() {
const d = state.viewData;
if (!d || !d.hash) return;
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
$("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber);
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-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("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");
showView("success-tx");
}

View File

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

View File

@@ -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
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. Replace the normal tx with the token transfer,
// but preserve contract call metadata (direction, label, method) so
// swaps and other contract interactions display correctly.
// amount and symbol. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
@@ -164,8 +166,13 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
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()];

View File

@@ -445,12 +445,18 @@ function decode(data, toAddress) {
const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff",
);
const amountStr =
inputAmount >= maxUint160
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals) +
(inSymbol ? " " + inSymbol : "");
details.push({ label: "Amount", value: amountStr });
const isUnlimited = inputAmount >= maxUint160;
const amountRaw = isUnlimited
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals);
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (inSymbol ? " " + inSymbol : "");
details.push({
label: "Amount",
value: amountStr,
rawValue: amountRaw,
});
}
if (outSymbol) {