Compare commits

..

47 Commits

Author SHA1 Message Date
user
443244809e fix: make block number click-to-copy with etherscan link
All checks were successful
check / check (push) Successful in 9s
Block number on success-tx view now has click-to-copy and an external
link to etherscan.io/block/{number}, consistent with how addresses and
transaction hashes are displayed.

Closes #99
2026-02-28 14:25: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
16 changed files with 490 additions and 553 deletions

View File

@@ -56,103 +56,39 @@
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Wallet</h2>
<!-- Mode selector tabs -->
<div class="flex gap-1 mb-3">
<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-mode-mnemonic"
class="border border-border px-2 py-1 cursor-pointer text-xs bg-fg text-bg"
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"
>
Recovery Phrase
</button>
<button
id="btn-mode-key"
class="border border-border px-2 py-1 cursor-pointer text-xs hover:bg-fg hover:text-bg"
>
Private Key
</button>
<button
id="btn-mode-xprv"
class="border border-border px-2 py-1 cursor-pointer text-xs hover:bg-fg hover:text-bg"
>
xprv
[&#9856;]
</button>
</div>
<!-- ---- Mnemonic mode ---- -->
<div id="add-wallet-mode-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 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>
<!-- ---- Private key mode ---- -->
<div id="add-wallet-mode-key" 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
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>
<!-- ---- xprv mode ---- -->
<div id="add-wallet-mode-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
id="add-wallet-password-hint"
class="text-xs text-muted mb-1"
>
This password encrypts your secret on this device. You
will need it to send funds.
<p class="text-xs text-muted mb-1">
This password encrypts your recovery phrase on this
device. You will need it to send funds.
</p>
<input
type="password"
@@ -174,6 +110,64 @@
>
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>
<!-- ============ 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>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
@@ -502,6 +496,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">
@@ -582,11 +581,23 @@
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>
@@ -665,42 +676,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
@@ -1140,7 +1115,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"
@@ -1203,7 +1181,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,6 +10,7 @@ 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 addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
@@ -53,6 +54,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(),
@@ -72,6 +74,7 @@ const RESTORABLE_VIEWS = new Set([
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
@@ -125,6 +128,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();
@@ -207,6 +217,7 @@ async function init() {
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);

View File

@@ -1,62 +1,48 @@
const { $, showView, showFlash, goBack } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const {
generateMnemonic,
hdWalletFromMnemonic,
hdWalletFromXprv,
isValidMnemonic,
isValidXprv,
addressFromPrivateKey,
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
let currentMode = "mnemonic"; // "mnemonic" | "key" | "xprv"
const MODES = ["mnemonic", "key", "xprv"];
function setMode(mode) {
currentMode = mode;
for (const m of MODES) {
const section = $("add-wallet-mode-" + m);
if (section) section.classList.toggle("hidden", m !== mode);
const btn = $("btn-mode-" + m);
if (btn) {
if (m === mode) {
btn.classList.add("bg-fg", "text-bg");
btn.classList.remove("hover:bg-fg", "hover:text-bg");
} else {
btn.classList.remove("bg-fg", "text-bg");
btn.classList.add("hover:bg-fg", "hover:text-bg");
}
}
}
}
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");
setMode("mnemonic");
showView("add-wallet");
}
function init(ctx) {
// Mode switching
$("btn-mode-mnemonic").addEventListener("click", () => setMode("mnemonic"));
$("btn-mode-key").addEventListener("click", () => setMode("key"));
$("btn-mode-xprv").addEventListener("click", () => setMode("xprv"));
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden");
});
$("btn-add-wallet-confirm").addEventListener("click", async () => {
// Shared password validation
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 = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
@@ -71,187 +57,73 @@ function init(ctx) {
showFlash("Passwords do not match.");
return;
}
if (currentMode === "mnemonic") {
await handleMnemonic(ctx, pw);
} else if (currentMode === "key") {
await handlePrivateKey(ctx, pw);
} else if (currentMode === "xprv") {
await handleXprv(ctx, pw);
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();
});
$("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) {
goBack("welcome");
showView("welcome");
} else {
ctx.renderWalletList();
goBack("main");
showView("main");
}
});
}
async function handleMnemonic(ctx, pw) {
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 { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
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");
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 handlePrivateKey(ctx, pw) {
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 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 handleXprv(ctx, pw) {
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 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");
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();
}
module.exports = { init, show };

View File

@@ -15,7 +15,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");
const { decryptWithPassword } = require("../../shared/vault");
@@ -259,6 +263,7 @@ function init(_ctx) {
$("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
updateSendBalance();
resetSendValidation();
showView("send");
});
@@ -320,6 +325,9 @@ function init(_ctx) {
$("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(
@@ -339,6 +347,9 @@ function init(_ctx) {
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
});

View File

@@ -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");
@@ -372,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,10 +181,20 @@ 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;
}
}
@@ -254,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");
}
@@ -342,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");
@@ -407,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");
@@ -447,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");
@@ -469,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,9 +233,14 @@ 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");
estimateGas(txInfo);
@@ -262,39 +286,20 @@ 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");
}
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 +307,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 +349,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,6 +13,7 @@ const { state, saveState } = require("../../shared/state");
const VIEWS = [
"welcome",
"add-wallet",
"import-key",
"main",
"address",
"address-token",
@@ -33,9 +34,6 @@ const VIEWS = [
"export-privkey",
];
// Simple view history stack for back-button navigation.
const viewStack = [];
function $(id) {
return document.getElementById(id);
}
@@ -50,13 +48,7 @@ function hideError(id) {
$(id).classList.add("hidden");
}
function showView(name, opts) {
const skipPush = opts && opts.skipPush;
if (!skipPush && state.currentView && state.currentView !== name) {
viewStack.push(state.currentView);
// Keep the stack bounded to avoid unbounded growth.
if (viewStack.length > 20) viewStack.splice(0, viewStack.length - 20);
}
function showView(name) {
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
@@ -74,17 +66,6 @@ function showView(name, opts) {
}
}
// Navigate to the previous view in the stack. Falls back to fallbackView
// (default "main") when the stack is empty.
function goBack(fallbackView) {
const prev = viewStack.pop();
if (prev) {
showView(prev, { skipPush: true });
} else {
showView(fallbackView || "main", { skipPush: true });
}
}
let flashTimer = null;
function clearFlash() {
@@ -283,7 +264,6 @@ module.exports = {
showError,
hideError,
showView,
goBack,
showFlash,
balanceLine,
balanceLinesForAddress,

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,
@@ -235,7 +239,7 @@ function render(ctx) {
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd" || wallet.type === "xprv") {
if (wallet.type === "hd") {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
}
html += `</div>`;
@@ -388,6 +392,7 @@ function init(ctx) {
$("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr);
updateSendBalance();
resetSendValidation();
showView("send");
});

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

@@ -11,6 +11,107 @@ const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
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">` +
@@ -88,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();
@@ -95,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;
@@ -159,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

@@ -43,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) {
@@ -58,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)
@@ -139,7 +150,7 @@ function etherscanTokenLink(address) {
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = "";
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>`;
@@ -164,20 +175,36 @@ function decodedDetailsHtml(decoded) {
}
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 && d.decoded) {
if (decodedEl && hasDecoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {

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) {

View File

@@ -24,26 +24,6 @@ function hdWalletFromMnemonic(mnemonic) {
return { xpub, firstAddress };
}
function hdWalletFromXprv(xprv) {
const root = HDNodeWallet.fromExtendedKey(xprv);
if (!root.privateKey) {
throw new Error("Not an extended private key (xprv).");
}
const node = root.derivePath("44'/60'/0'/0");
const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress };
}
function isValidXprv(key) {
try {
const node = HDNodeWallet.fromExtendedKey(key);
return !!node.privateKey;
} catch {
return false;
}
}
function addressFromPrivateKey(key) {
const w = new Wallet(key);
return w.address;
@@ -58,11 +38,6 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
);
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
const root = HDNodeWallet.fromExtendedKey(decryptedSecret);
const node = root.derivePath("44'/60'/0'/0");
return node.deriveChild(addrIndex);
}
return new Wallet(decryptedSecret);
}
@@ -74,8 +49,6 @@ module.exports = {
generateMnemonic,
deriveAddressFromXpub,
hdWalletFromMnemonic,
hdWalletFromXprv,
isValidXprv,
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,