Compare commits

..

39 Commits

Author SHA1 Message Date
user
e13af842df feat: reorganize transaction detail view layout
All checks were successful
check / check (push) Successful in 22s
- Move transaction hash (txid) to first position after title
- Group fields into logical sections with visual dividers:
  Identity (hash, type, status, time), Value (amount, native qty,
  token contract), Parties (from, to), Protocol (calldata/action),
  On-chain (block, nonce, fee, gas price, gas used), Raw data
- Add tx-detail-group CSS class for subtle border separators
- Show on-chain details group wrapper only when data is loaded
- Maintain all existing functionality and copy-to-clipboard behavior

closes #131
2026-03-01 07:23:46 -08:00
6aeab54e8c Merge pull request 'fix: correct swap display — output token, min received, from address, and amount' (#128) from fix/issue-127-swap-amount-display into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #128
2026-03-01 16:19:41 +01:00
f65764d501 Merge branch 'main' into fix/issue-127-swap-amount-display
All checks were successful
check / check (push) Successful in 21s
2026-03-01 16:19:26 +01:00
4e097c1e32 Merge pull request 'feat: add Type field and on-chain details to transaction detail view' (#130) from fix/issue-95-transaction-type-display into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #130
2026-03-01 15:46:33 +01:00
clawbot
3f6f98dcaf docs: update TransactionDetail screen map with new fields
All checks were successful
check / check (push) Successful in 22s
Add Type, Token contract, Block, Nonce, Transaction fee, Gas price,
and Gas used fields to the TransactionDetail section in the README
screen map to match the implemented UI.
2026-03-01 06:17:40 -08:00
user
3e900dc14c feat: add Type field and on-chain details to transaction detail view
All checks were successful
check / check (push) Successful in 9s
- Always display a Type field as the first item under the Transaction
  heading, identifying the transaction as: Native ETH Transfer, ERC-20
  Token Transfer, Swap, Token Approval, Contract Call, or Contract Creation
- Show token contract address with identicon for ERC-20 transfers
- Fetch and display on-chain details from Blockscout: block number,
  nonce, transaction fee, gas price, and gas used
- All new fields are click-copyable with Etherscan links where applicable

closes #95
2026-03-01 06:11:35 -08:00
user
5dfc6e332b fix: correct swap display — output token, min received, from address, and amount
All checks were successful
check / check (push) Successful in 23s
Fix multi-step Uniswap swap decoding and transaction display:

1. uniswap.js: In multi-step swaps (e.g. V3 → V4), the output token and
   min received amount now come from the LAST swap step instead of the
   first. Previously, an intermediate token's amountOutMin (18 decimals)
   was formatted with the final token's decimals (6), producing
   astronomically wrong 'Min. received' values (~2 trillion USDC).

2. transactions.js: Contract call token transfers (swaps) are now
   consolidated into the original transaction entry instead of creating
   separate entries per token transfer. This prevents intermediate hop
   tokens (e.g. USDS in a USDT→USDS→USDC route) from appearing as the
   transaction's Amount. The received token (swap output) is preferred.

3. transactions.js: The original transaction's from/to addresses are
   preserved for contract calls, so the user sees their own address
   instead of a router or Permit2 contract address.

closes #127
2026-03-01 05:52:10 -08:00
a182aa534b Merge pull request 'fix: include timezone offset in all displayed timestamps (closes #116)' (#120) from fix/issue-116-timestamp-timezone into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #120
2026-03-01 13:36:04 +01:00
a388100262 Merge branch 'main' into fix/issue-116-timestamp-timezone
All checks were successful
check / check (push) Successful in 22s
2026-03-01 13:35:26 +01:00
dd3cabf816 Merge pull request 'feat: add theme setting (Light/Dark/System) with dark mode — closes #125' (#126) from feat/issue-125-dark-mode into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #126
2026-03-01 13:35:11 +01:00
user
235e5e7fa7 fix: improve dark mode contrast for hover, well, section, and border colors
All checks were successful
check / check (push) Successful in 22s
2026-03-01 03:49:18 -08:00
user
be06bd8f0c fix: improve dark mode contrast for wells and balance display
All checks were successful
check / check (push) Successful in 10s
- Change dark mode --color-well from #0a0a0a to #111111 for visible
  contrast against #000000 background
- Add explicit text-fg class to balance display element to ensure
  white text in dark mode
2026-03-01 03:38:27 -08:00
user
a72359432b fix: include timezone offset in all displayed timestamps
All checks were successful
check / check (push) Successful in 21s
All isoDate() functions now output proper ISO 8601 format with timezone
offset (e.g. 2026-02-28T15:30:00-08:00) instead of bare datetime strings.
Also uses 'T' separator per ISO 8601.

closes #116
2026-03-01 03:36:49 -08:00
user
2bdb547995 feat: add theme setting (Light/Dark/System) with dark mode
Add theme preference (light/dark/system) stored in extension state.
System mode follows prefers-color-scheme and listens for changes.
Dark mode inverts the monochrome palette (white-on-black).
Theme selector added to Display section in settings.

Closes #125
2026-03-01 03:36:42 -08:00
834228b572 Merge pull request 'fix: reserve space for all error/status messages — closes #123' (#124) from fix/issue-123-layout-shift-audit into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #124
2026-03-01 12:33:08 +01:00
clawbot
813993f17c fix: reserve space for all error/status messages — closes #123
All checks were successful
check / check (push) Successful in 22s
Replace display:none (hidden class) with visibility:hidden/visible for all
error, warning, and status message elements across the extension UI. This
prevents layout shift when messages appear or disappear.

Changes:
- helpers.js: showError/hideError now use visibility instead of hidden class
- index.html: all error/status divs use visibility:hidden + min-height
- confirmTx.js: warnings, errors, fee section use visibility
- approval.js: tx-error, sign-error, danger-warning use visibility
- addressDetail.js: export-privkey-flash uses visibility
- deleteWallet.js: delete-wallet-flash uses visibility
- addWallet.js: phrase-warning uses visibility
- receive.js: erc20-warning uses visibility
- addToken.js: add-token-info uses visibility
- settingsAddToken.js: settings-addtoken-info uses visibility
2026-02-28 16:30:43 -08:00
5f01d9f111 Merge pull request 'feat: speed up copy-flash timing by ~25% — follow-up to #113' (#121) from fix/issue-100-faster-copy-flash into main
All checks were successful
check / check (push) Successful in 11s
Reviewed-on: #121
2026-03-01 01:21:24 +01:00
user
d78af3ec80 feat: speed up copy-flash timing by ~25%
All checks were successful
check / check (push) Successful in 20s
Reduce active phase from 100ms to 75ms, fade transition from 300ms to
225ms, and cleanup delay from 350ms to 275ms for snappier feedback.

Refs #100
2026-02-28 16:17:07 -08:00
753fb5658a Merge pull request 'fix: cross-wallet-type duplicate detection — closes #111' (#115) from fix/issue-111-cross-wallet-dedup into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #115
2026-03-01 01:13:15 +01:00
bdb2031d46 Merge branch 'main' into fix/issue-111-cross-wallet-dedup
All checks were successful
check / check (push) Successful in 21s
2026-03-01 01:13:06 +01:00
25ecaee128 Merge pull request 'feat: add copy-flash visual feedback — closes #100' (#113) from fix/issue-100-copy-flash-feedback into main
All checks were successful
check / check (push) Successful in 21s
Reviewed-on: #113
2026-03-01 01:12:40 +01:00
user
ff4b5ee24d feat: add copy-flash visual feedback on click-to-copy
All checks were successful
check / check (push) Successful in 9s
When a user clicks to copy text (addresses, tx hashes, etc.), the copied
element now briefly flashes with inverted colors (bg/fg swap) and fades
back over ~300ms. This provides localized visual feedback in addition to
the existing flash message.

Applied to all click-to-copy elements across all views.

closes #100
2026-03-01 01:01:34 +01:00
user
ca6e9054f9 fix: cross-wallet-type duplicate detection for all import methods
All checks were successful
check / check (push) Successful in 22s
- Private key import now checks ALL wallets (hd, xprv, key) for address conflicts
- xprv import now checks xpub against existing xpubs and addresses across all wallet types
- Mnemonic import now checks xpub against xprv wallets and addresses across all types
- Extract findWalletByAddress() and findWalletByXpub() helpers for consistent dedup

closes #111
2026-02-28 15:58:47 -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
24 changed files with 1000 additions and 383 deletions

View File

@@ -437,6 +437,10 @@ transitions.
- **When**: User tapped a transaction row from AddressDetail or AddressToken. - **When**: User tapped a transaction row from AddressDetail or AddressToken.
- **Elements**: - **Elements**:
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
- Token contract: shown for ERC-20 transfers — color dot + full contract
address (tap to copy) + etherscan token link
- Status: "Success" or "Failed" - Status: "Success" or "Failed"
- Time: ISO datetime + relative age in parentheses - Time: ISO datetime + relative age in parentheses
- Amount: value + symbol (bold) - Amount: value + symbol (bold)
@@ -445,6 +449,11 @@ transitions.
- To: blockie + color dot + full address (tap to copy) + etherscan link - To: blockie + color dot + full address (tap to copy) + etherscan link
- ENS name if available - ENS name if available
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- Block: block number (tap to copy) + etherscan block link
- Nonce: transaction nonce (tap to copy)
- Transaction fee: ETH amount (tap to copy)
- Gas price: value in Gwei (tap to copy)
- Gas used: integer (tap to copy)
- **Transitions**: - **Transitions**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** - "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**

View File

@@ -56,9 +56,37 @@
&lt; Back &lt; Back
</button> </button>
<h2 class="font-bold mb-2">Add Wallet</h2> <h2 class="font-bold mb-2">Add Wallet</h2>
<!-- Mode selector tabs -->
<div
class="flex border-b border-border mb-3"
id="add-wallet-tabs"
>
<button
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"
>
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>
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<p class="mb-2"> <p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the Enter your 12 or 24 word recovery phrase below, or click
button to roll the die for a new one. the button to roll the die for a new one.
</p> </p>
<div class="mb-1 flex justify-end"> <div class="mb-1 flex justify-end">
<button <button
@@ -79,14 +107,54 @@
</div> </div>
<div <div
id="add-wallet-phrase-warning" id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden" class="text-xs mb-2 border border-border border-dashed p-2"
style="visibility: hidden"
> >
Write these words down and keep them safe. Anyone with them Write these words down and keep them safe. Anyone with
can take your funds; if you lose them, your wallet is gone. them can take your funds; if you lose them, your wallet
is gone.
</div> </div>
</div>
<!-- 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"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <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 This password encrypts your recovery phrase on this
device. You will need it to send funds. device. You will need it to send funds.
</p> </p>
@@ -107,64 +175,6 @@
<button <button
id="btn-add-wallet-confirm" id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
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 Import
</button> </button>
@@ -175,7 +185,7 @@
<!-- active address headline --> <!-- active address headline -->
<div <div
id="total-value" id="total-value"
class="text-2xl font-bold min-h-[2rem]" class="text-2xl font-bold min-h-[2rem] text-fg"
></div> ></div>
<div <div
id="total-value-sub" id="total-value-sub"
@@ -366,7 +376,8 @@
</p> </p>
<div <div
id="export-privkey-flash" id="export-privkey-flash"
class="text-xs mb-2 hidden" class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div id="export-privkey-password-section" class="mb-2"> <div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -570,13 +581,17 @@
<div class="text-xs text-muted mb-1">Your balance</div> <div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div> <div id="confirm-balance" class="text-xs"></div>
</div> </div>
<div id="confirm-fee" class="mb-3 hidden"> <div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div class="text-xs text-muted mb-1"> <div class="text-xs text-muted mb-1">
Estimated network fee Estimated network fee
</div> </div>
<div id="confirm-fee-amount" class="text-xs"></div> <div id="confirm-fee-amount" class="text-xs"></div>
</div> </div>
<div id="confirm-warnings" class="mb-2 hidden"></div> <div
id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div <div
id="confirm-recipient-warning" id="confirm-recipient-warning"
class="mb-2" class="mb-2"
@@ -592,7 +607,8 @@
</div> </div>
<div <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden" class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
@@ -605,6 +621,7 @@
<div <div
id="confirm-tx-password-error" id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]" class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-confirm-send" id="btn-confirm-send"
@@ -719,7 +736,8 @@
</button> </button>
<div <div
id="receive-erc20-warning" id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3 hidden" class="text-xs border border-border border-dashed p-2 mt-3"
style="visibility: hidden"
></div> ></div>
</div> </div>
@@ -747,7 +765,8 @@
</div> </div>
<div <div
id="add-token-info" id="add-token-info"
class="text-xs text-muted mb-2 hidden" class="text-xs text-muted mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs text-muted" <label class="block mb-1 text-xs text-muted"
@@ -805,7 +824,7 @@
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3> <h3 class="font-bold mb-1">Display</h3>
<label <label
class="text-xs flex items-center gap-1 cursor-pointer" class="text-xs flex items-center gap-1 cursor-pointer mb-2"
> >
<input <input
type="checkbox" type="checkbox"
@@ -813,6 +832,17 @@
/> />
Show tracked tokens with zero balance Show tracked tokens with zero balance
</label> </label>
<div class="text-xs flex items-center gap-1">
<label for="settings-theme">Theme:</label>
<select
id="settings-theme"
class="border border-border p-1 bg-bg text-fg text-xs cursor-pointer"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
@@ -894,6 +924,12 @@
/> />
<span class="text-xs text-muted">gwei</span> <span class="text-xs text-muted">gwei</span>
</div> </div>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
@@ -929,7 +965,8 @@
</p> </p>
<div <div
id="delete-wallet-flash" id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 hidden" class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -1004,7 +1041,8 @@
/> />
<div <div
id="settings-addtoken-info" id="settings-addtoken-info"
class="text-xs text-muted mt-1 hidden" class="text-xs text-muted mt-1 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-settings-addtoken-manual" id="btn-settings-addtoken-manual"
@@ -1026,38 +1064,82 @@
<h2 id="tx-detail-heading" class="font-bold mb-2"> <h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction Transaction
</h2> </h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div> <!-- ── Identity ── -->
<div id="tx-detail-type" class="text-xs font-bold"></div> <div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">
Transaction hash
</div> </div>
<div class="mb-4"> <div
id="tx-detail-hash"
class="text-xs break-all"
></div>
</div>
<div id="tx-detail-type-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div
id="tx-detail-type"
class="text-xs font-bold"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Status</div> <div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div> <div id="tx-detail-status" class="text-xs"></div>
</div> </div>
<div class="mb-4"> <div class="mb-1">
<div class="text-xs text-muted mb-1">Time</div> <div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></div> <div id="tx-detail-time" class="text-xs"></div>
</div> </div>
<div class="mb-4"> </div>
<!-- ── Value ── -->
<div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div> <div class="text-xs text-muted mb-1">Amount</div>
<div id="tx-detail-value" class="text-xs"></div> <div id="tx-detail-value" class="text-xs"></div>
</div> </div>
<div class="mb-4 hidden"> <div class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Native quantity</div> <div class="text-xs text-muted mb-1">
Native quantity
</div>
<div id="tx-detail-native" class="text-xs"></div> <div id="tx-detail-native" class="text-xs"></div>
</div> </div>
<div class="mb-4"> <div
<div class="text-xs text-muted mb-1">From</div> id="tx-detail-token-contract-section"
<div id="tx-detail-from" class="text-xs break-all"></div> class="mb-1 hidden"
>
<div class="text-xs text-muted mb-1">
Token contract
</div> </div>
<div class="mb-4"> <div
id="tx-detail-token-contract"
class="text-xs break-all"
></div>
</div>
</div>
<!-- ── Parties ── -->
<div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
</div>
<div class="mb-1">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div> <div id="tx-detail-to" class="text-xs break-all"></div>
</div> </div>
<div id="tx-detail-calldata-section" class="mb-4 hidden"> </div>
<!-- ── Protocol ── -->
<div id="tx-detail-calldata-section" class="mb-1 hidden">
<div class="tx-detail-group mb-1">
<div <div
id="tx-detail-calldata-well" id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2" class="border border-border border-dashed p-2"
> >
<div class="text-xs text-muted mb-1">Action</div> <div class="text-xs text-muted mb-1">Action</div>
<div <div
@@ -1070,11 +1152,40 @@
></div> ></div>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
</div> </div>
<!-- ── On-chain details ── -->
<div
id="tx-detail-onchain-group"
class="tx-detail-group mb-1 hidden"
>
<div id="tx-detail-block-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Block</div>
<div id="tx-detail-block" class="text-xs"></div>
</div>
<div id="tx-detail-nonce-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Nonce</div>
<div id="tx-detail-nonce" class="text-xs"></div>
</div>
<div id="tx-detail-fee-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Transaction fee
</div>
<div id="tx-detail-fee" class="text-xs"></div>
</div>
<div id="tx-detail-gasprice-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Gas price</div>
<div id="tx-detail-gasprice" class="text-xs"></div>
</div>
<div id="tx-detail-gasused-section" class="mb-1 hidden">
<div class="text-xs text-muted mb-1">Gas used</div>
<div id="tx-detail-gasused" class="text-xs"></div>
</div>
</div>
<!-- ── Raw data ── -->
<div id="tx-detail-rawdata-section" class="mb-4 hidden"> <div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="tx-detail-group">
<div class="text-xs text-muted mb-1">Raw data</div> <div class="text-xs text-muted mb-1">Raw data</div>
<div <div
id="tx-detail-rawdata" id="tx-detail-rawdata"
@@ -1082,6 +1193,7 @@
></div> ></div>
</div> </div>
</div> </div>
</div>
<!-- ============ TRANSACTION APPROVAL ============ --> <!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden"> <div id="view-approve-tx" class="view hidden">
@@ -1130,7 +1242,8 @@
</div> </div>
<div <div
id="approve-tx-error" id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
@@ -1158,8 +1271,10 @@
<div <div
id="approve-sign-danger-warning" id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold" class="mb-3 p-2 text-xs font-bold"
style=" style="
visibility: hidden;
min-height: 1.25rem;
background: #fee2e2; background: #fee2e2;
color: #991b1b; color: #991b1b;
border: 2px solid #dc2626; border: 2px solid #dc2626;
@@ -1196,7 +1311,8 @@
</div> </div>
<div <div
id="approve-sign-error" id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button

View File

@@ -6,11 +6,11 @@ const { state, saveState, loadState } = require("../shared/state");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { $, showView } = require("./views/helpers"); const { $, showView } = require("./views/helpers");
const { applyTheme } = require("./theme");
const home = require("./views/home"); const home = require("./views/home");
const welcome = require("./views/welcome"); const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet"); const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail"); const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken"); const addressToken = require("./views/addressToken");
const send = require("./views/send"); const send = require("./views/send");
@@ -54,7 +54,6 @@ const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
showAddWalletView: () => addWallet.show(), showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(), showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(), showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(), showAddTokenView: () => addToken.show(),
@@ -178,6 +177,7 @@ async function init() {
} }
await loadState(); await loadState();
applyTheme(state.theme);
// Auto-default active address // Auto-default active address
if ( if (
@@ -217,7 +217,6 @@ async function init() {
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx); home.init(ctx);
addressDetail.init(ctx); addressDetail.init(ctx);
addressToken.init(ctx); addressToken.init(ctx);

View File

@@ -15,7 +15,40 @@
--color-section: #dddddd; --color-section: #dddddd;
} }
html.dark {
--color-bg: #000000;
--color-fg: #ffffff;
--color-muted: #aaaaaa;
--color-border: #ffffff;
--color-border-light: #444444;
--color-hover: #222222;
--color-well: #1a1a1a;
--color-danger-well: #2a0a0a;
--color-section: #2a2a2a;
}
body { body {
width: 396px; width: 396px;
overflow-x: hidden; overflow-x: hidden;
} }
/* Copy-flash feedback: inverts colors then fades back */
.copy-flash-active {
background-color: var(--color-fg) !important;
color: var(--color-bg) !important;
transition: none;
}
.copy-flash-fade {
transition:
background-color 225ms ease-out,
color 225ms ease-out;
}
/* Transaction detail view — visual grouping of related fields */
.tx-detail-group {
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
padding-top: 0.25rem;
}

33
src/popup/theme.js Normal file
View File

@@ -0,0 +1,33 @@
// Theme management: applies light/dark class to <html> based on preference.
let mediaQuery = null;
let mediaHandler = null;
function applyTheme(theme) {
// Clean up previous system listener
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else if (theme === "light") {
document.documentElement.classList.remove("dark");
} else {
// system
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => {
if (mediaQuery.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
mediaHandler = update;
mediaQuery.addEventListener("change", update);
update();
}
}
module.exports = { applyTheme };

View File

@@ -7,7 +7,8 @@ const { log } = require("../../shared/log");
function show() { function show() {
$("add-token-address").value = ""; $("add-token-address").value = "";
$("add-token-info").classList.add("hidden"); $("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
const list = $("common-token-list"); const list = $("common-token-list");
list.innerHTML = getTopTokens(25) list.innerHTML = getTopTokens(25)
.map( .map(
@@ -45,7 +46,7 @@ function init(ctx) {
} }
const infoEl = $("add-token-info"); const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token..."; infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden"); infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr); log.debugf("Looking up token contract", contractAddr);
try { try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl); const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
@@ -63,7 +64,8 @@ function init(ctx) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail); log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail); showFlash(detail);
infoEl.classList.add("hidden"); infoEl.textContent = "";
infoEl.style.visibility = "hidden";
} }
}); });

View File

@@ -3,31 +3,101 @@ const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
isValidMnemonic, isValidMnemonic,
addressFromPrivateKey,
hdWalletFromXprv,
isValidXprv,
} = require("../../shared/wallet"); } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances"); const { scanForAddresses } = require("../../shared/balances");
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
* Returns the wallet object if found, or undefined.
*/
function findWalletByAddress(addr) {
const lower = addr.toLowerCase();
return state.wallets.find((w) =>
w.addresses.some((a) => a.address.toLowerCase() === lower),
);
}
/**
* Check if an xpub already exists in any HD-type wallet (hd or xprv).
* Returns the wallet object if found, or undefined.
*/
function findWalletByXpub(xpub) {
return state.wallets.find((w) => w.xpub && w.xpub === xpub);
}
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() { function show() {
$("wallet-mnemonic").value = ""; $("wallet-mnemonic").value = "";
$("import-private-key").value = "";
$("import-xprv-key").value = "";
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden"); $("add-wallet-phrase-warning").style.visibility = "hidden";
switchMode("mnemonic");
showView("add-wallet"); showView("add-wallet");
} }
function init(ctx) { function validatePassword() {
$("btn-generate-phrase").addEventListener("click", () => { const pw = $("add-wallet-password").value;
$("wallet-mnemonic").value = generateMnemonic(); const pw2 = $("add-wallet-password-confirm").value;
$("add-wallet-phrase-warning").classList.remove("hidden"); 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;
}
$("btn-add-wallet-confirm").addEventListener("click", async () => { async function importMnemonic(ctx) {
const mnemonic = $("wallet-mnemonic").value.trim(); const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) { if (!mnemonic) {
showFlash( showFlash("Enter a recovery phrase or press the die to generate one.");
"Enter a recovery phrase or press the die to generate one.",
);
return; return;
} }
const words = mnemonic.split(/\s+/); const words = mnemonic.split(/\s+/);
@@ -43,36 +113,21 @@ function init(ctx) {
showFlash("Invalid recovery phrase. Check for typos."); showFlash("Invalid recovery phrase. Check for typos.");
return; return;
} }
const pw = $("add-wallet-password").value; const pw = validatePassword();
const pw2 = $("add-wallet-password-confirm").value; if (!pw) return;
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 { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find( const xpubDup = findWalletByXpub(xpub);
(w) => if (xpubDup) {
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash( showFlash(
"This recovery phrase is already added (" + "This recovery phrase is already added (" + xpubDup.name + ").",
duplicate.name +
").",
); );
return; return;
} }
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw); const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
const wallet = { const wallet = {
@@ -109,8 +164,138 @@ function init(ctx) {
} }
ctx.doRefreshAndRender(); 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 = findWalletByAddress(addr);
if (duplicate) {
showFlash(
"This address already exists in wallet (" + 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 xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash("This key is already added (" + xpubDup.name + ").");
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.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").style.visibility = "visible";
}); });
// Import / confirm
$("btn-add-wallet-confirm").addEventListener("click", async () => {
if (currentMode === "mnemonic") {
await importMnemonic(ctx);
} else if (currentMode === "privkey") {
await importPrivateKey(ctx);
} else if (currentMode === "xprv") {
await importXprvKey(ctx);
}
});
// Back button
$("btn-add-wallet-back").addEventListener("click", () => { $("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");
@@ -119,11 +304,6 @@ function init(ctx) {
showView("main"); showView("main");
} }
}); });
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -2,6 +2,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
@@ -94,18 +95,39 @@ function show() {
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return ( return (
d.getFullYear() + d.getFullYear() +
"-" + "-" +
pad(d.getMonth() + 1) + pad(d.getMonth() + 1) +
"-" + "-" +
pad(d.getDate()) + pad(d.getDate()) +
" " + "T" +
pad(d.getHours()) + pad(d.getHours()) +
":" + ":" +
pad(d.getMinutes()) + pad(d.getMinutes()) +
":" + ":" +
pad(d.getSeconds()) pad(d.getSeconds()) +
tzStr
); );
} }
@@ -241,6 +263,7 @@ function init(_ctx) {
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("address-full"));
} }
}); });
@@ -310,8 +333,8 @@ function init(_ctx) {
$("export-privkey-address").textContent = addr.address; $("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address; $("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").textContent = ""; $("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
$("export-privkey-password-section").classList.remove("hidden"); $("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden"); $("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = ""; $("export-privkey-value").textContent = "";
@@ -322,7 +345,7 @@ function init(_ctx) {
const password = $("export-privkey-password").value; const password = $("export-privkey-password").value;
if (!password) { if (!password) {
$("export-privkey-flash").textContent = "Password is required."; $("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").classList.remove("hidden"); $("export-privkey-flash").style.visibility = "visible";
return; return;
} }
const btn = $("btn-export-privkey-confirm"); const btn = $("btn-export-privkey-confirm");
@@ -343,10 +366,10 @@ function init(_ctx) {
$("export-privkey-password-section").classList.add("hidden"); $("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey; $("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden"); $("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").classList.add("hidden"); $("export-privkey-flash").style.visibility = "hidden";
} catch { } catch {
$("export-privkey-flash").textContent = "Wrong password."; $("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden"); $("export-privkey-flash").style.visibility = "visible";
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
@@ -358,6 +381,7 @@ function init(_ctx) {
if (key) { if (key) {
navigator.clipboard.writeText(key); navigator.clipboard.writeText(key);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
} }
}); });
@@ -366,6 +390,7 @@ function init(_ctx) {
if (full) { if (full) {
navigator.clipboard.writeText(full); navigator.clipboard.writeText(full);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("export-privkey-address"));
} }
}); });

View File

@@ -5,6 +5,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -47,18 +48,39 @@ function etherscanAddressLink(address) {
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return ( return (
d.getFullYear() + d.getFullYear() +
"-" + "-" +
pad(d.getMonth() + 1) + pad(d.getMonth() + 1) +
"-" + "-" +
pad(d.getDate()) + pad(d.getDate()) +
" " + "T" +
pad(d.getHours()) + pad(d.getHours()) +
":" + ":" +
pad(d.getMinutes()) + pad(d.getMinutes()) +
":" + ":" +
pad(d.getSeconds()) pad(d.getSeconds()) +
tzStr
); );
} }
@@ -317,6 +339,7 @@ function init(_ctx) {
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("address-token-full"));
} }
}); });
@@ -325,6 +348,7 @@ function init(_ctx) {
if (copyEl) { if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy); navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(copyEl);
} }
}); });
@@ -373,6 +397,7 @@ function init(_ctx) {
copyEl.addEventListener("click", () => { copyEl.addEventListener("click", () => {
navigator.clipboard.writeText(copyEl.dataset.copy); navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(copyEl);
}); });
} }
updateSendBalance(); updateSendBalance();

View File

@@ -269,7 +269,7 @@ function showTxApproval(details) {
} }
$("approve-tx-password").value = ""; $("approve-tx-password").value = "";
$("approve-tx-error").classList.add("hidden"); hideError("approve-tx-error");
showView("approve-tx"); showView("approve-tx");
} }
@@ -351,10 +351,10 @@ function showSignApproval(details) {
if (warningEl) { if (warningEl) {
if (sp.dangerWarning) { if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning; warningEl.textContent = sp.dangerWarning;
warningEl.classList.remove("hidden"); warningEl.style.visibility = "visible";
} else { } else {
warningEl.textContent = ""; warningEl.textContent = "";
warningEl.classList.add("hidden"); warningEl.style.visibility = "hidden";
} }
} }

View File

@@ -15,6 +15,7 @@ const {
hideError, hideError,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressTitle, addressTitle,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
@@ -117,6 +118,7 @@ function show(txInfo) {
copyEl.onclick = () => { copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy); navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(copyEl);
}; };
} }
} else { } else {
@@ -184,9 +186,10 @@ function show(txInfo) {
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`, `<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
) )
.join(""); .join("");
warningsEl.classList.remove("hidden"); warningsEl.style.visibility = "visible";
} else { } else {
warningsEl.classList.add("hidden"); warningsEl.innerHTML = "";
warningsEl.style.visibility = "hidden";
} }
// Check for errors // Check for errors
@@ -224,11 +227,12 @@ function show(txInfo) {
errorsEl.innerHTML = errors errorsEl.innerHTML = errors
.map((e) => `<div class="text-xs">${e}</div>`) .map((e) => `<div class="text-xs">${e}</div>`)
.join(""); .join("");
errorsEl.classList.remove("hidden"); errorsEl.style.visibility = "visible";
sendBtn.disabled = true; sendBtn.disabled = true;
sendBtn.classList.add("text-muted"); sendBtn.classList.add("text-muted");
} else { } else {
errorsEl.classList.add("hidden"); errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
sendBtn.disabled = false; sendBtn.disabled = false;
sendBtn.classList.remove("text-muted"); sendBtn.classList.remove("text-muted");
} }
@@ -238,15 +242,13 @@ function show(txInfo) {
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
// Gas estimate — show placeholder then fetch async // Gas estimate — show placeholder then fetch async
$("confirm-fee").classList.remove("hidden"); $("confirm-fee").style.visibility = "visible";
$("confirm-fee-amount").textContent = "Estimating..."; $("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo }; state.viewData = { pendingTx: txInfo };
showView("confirm-tx"); showView("confirm-tx");
// Reset recipient warning: reserve space (visibility:hidden) while // Reset recipient warning to hidden (space always reserved, no layout shift)
// the async check runs, preventing layout shift per README policy. $("confirm-recipient-warning").style.visibility = "hidden";
const recipientWarning = $("confirm-recipient-warning");
recipientWarning.style.visibility = "hidden";
estimateGas(txInfo); estimateGas(txInfo);
checkRecipientHistory(txInfo); checkRecipientHistory(txInfo);
@@ -301,18 +303,14 @@ async function checkRecipientHistory(txInfo) {
// the nonce, i.e. sent-tx count only). // the nonce, i.e. sent-tx count only).
const code = await provider.getCode(txInfo.to); const code = await provider.getCode(txInfo.to);
if (code && code !== "0x") { if (code && code !== "0x") {
// Contract address — no warning needed, keep space reserved
// but invisible to prevent layout shift
return; return;
} }
const txCount = await provider.getTransactionCount(txInfo.to); const txCount = await provider.getTransactionCount(txInfo.to);
if (txCount === 0) { if (txCount === 0) {
el.style.visibility = "visible"; el.style.visibility = "visible";
} }
// If txCount > 0, leave visibility:hidden — space stays reserved
} catch (e) { } catch (e) {
log.errorf("recipient history check failed:", e.message); log.errorf("recipient history check failed:", e.message);
// On error, leave visibility:hidden — no layout shift, no false warning
} }
} }

View File

@@ -12,7 +12,7 @@ function show(walletIdx) {
wallet.name || "Wallet " + (walletIdx + 1); wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = ""; $("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = ""; $("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").classList.add("hidden"); $("delete-wallet-flash").style.visibility = "hidden";
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
@@ -29,14 +29,14 @@ function init(_ctx) {
if (!pw) { if (!pw) {
$("delete-wallet-flash").textContent = $("delete-wallet-flash").textContent =
"Please enter your password."; "Please enter your password.";
$("delete-wallet-flash").classList.remove("hidden"); $("delete-wallet-flash").style.visibility = "visible";
return; return;
} }
if (deleteWalletIndex === null) { if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent = $("delete-wallet-flash").textContent =
"No wallet selected for deletion."; "No wallet selected for deletion.";
$("delete-wallet-flash").classList.remove("hidden"); $("delete-wallet-flash").style.visibility = "visible";
return; return;
} }
@@ -52,7 +52,7 @@ function init(_ctx) {
await decryptWithPassword(wallet.encryptedSecret, pw); await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) { } catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password."; $("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden"); $("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
return; return;

View File

@@ -13,7 +13,6 @@ const { state, saveState } = require("../../shared/state");
const VIEWS = [ const VIEWS = [
"welcome", "welcome",
"add-wallet", "add-wallet",
"import-key",
"main", "main",
"address", "address",
"address-token", "address-token",
@@ -41,11 +40,13 @@ function $(id) {
function showError(id, msg) { function showError(id, msg) {
const el = $(id); const el = $(id);
el.textContent = msg; el.textContent = msg;
el.classList.remove("hidden"); el.style.visibility = "visible";
} }
function hideError(id) { function hideError(id) {
$(id).classList.add("hidden"); const el = $(id);
el.textContent = "";
el.style.visibility = "hidden";
} }
function showView(name) { function showView(name) {
@@ -227,18 +228,39 @@ function formatAddressHtml(address, ensName, maxLen, title) {
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return ( return (
d.getFullYear() + d.getFullYear() +
"-" + "-" +
pad(d.getMonth() + 1) + pad(d.getMonth() + 1) +
"-" + "-" +
pad(d.getDate()) + pad(d.getDate()) +
" " + "T" +
pad(d.getHours()) + pad(d.getHours()) +
":" + ":" +
pad(d.getMinutes()) + pad(d.getMinutes()) +
":" + ":" +
pad(d.getSeconds()) pad(d.getSeconds()) +
tzStr
); );
} }
@@ -259,12 +281,26 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago"; return years + " year" + (years !== 1 ? "s" : "") + " ago";
} }
function flashCopyFeedback(el) {
if (!el) return;
el.classList.remove("copy-flash-fade");
el.classList.add("copy-flash-active");
setTimeout(() => {
el.classList.remove("copy-flash-active");
el.classList.add("copy-flash-fade");
setTimeout(() => {
el.classList.remove("copy-flash-fade");
}, 275);
}, 75);
}
module.exports = { module.exports = {
$, $,
showError, showError,
hideError, hideError,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
addressColor, addressColor,

View File

@@ -2,6 +2,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
isoDate, isoDate,
timeAgo, timeAgo,
@@ -85,9 +86,10 @@ function renderActiveAddress() {
el.innerHTML = el.innerHTML =
`<span class="underline decoration-dashed cursor-pointer" id="active-addr-copy">${dot}${escapeHtml(addr)}</span>` + `<span class="underline decoration-dashed cursor-pointer" id="active-addr-copy">${dot}${escapeHtml(addr)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
$("active-addr-copy").addEventListener("click", () => { $("active-addr-copy").addEventListener("click", (e) => {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}); });
} else { } else {
el.textContent = ""; el.textContent = "";
@@ -239,7 +241,7 @@ function render(ctx) {
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; 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>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd") { if (wallet.type === "hd" || wallet.type === "xprv") {
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 += `<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>`; html += `</div>`;

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

@@ -2,6 +2,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
formatAddressHtml, formatAddressHtml,
addressTitle, addressTitle,
} = require("./helpers"); } = require("./helpers");
@@ -52,19 +53,21 @@ function show() {
"This is an ERC-20 token. Only send " + "This is an ERC-20 token. Only send " +
symbol + symbol +
" on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss."; " on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss.";
warningEl.classList.remove("hidden"); warningEl.style.visibility = "visible";
} else { } else {
warningEl.classList.add("hidden"); warningEl.textContent = "";
warningEl.style.visibility = "hidden";
} }
showView("receive"); showView("receive");
} }
function init(ctx) { function init(ctx) {
$("receive-address-block").addEventListener("click", () => { $("receive-address-block").addEventListener("click", (e) => {
const addr = $("receive-address-block").dataset.full; const addr = $("receive-address-block").dataset.full;
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
} }
}); });
@@ -73,6 +76,7 @@ function init(ctx) {
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("receive-address-block"));
} }
}); });

View File

@@ -1,4 +1,5 @@
const { $, showView, showFlash, escapeHtml } = require("./helpers"); const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { applyTheme } = require("../theme");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants"); const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -214,6 +215,13 @@ function init(ctx) {
await saveState(); await saveState();
}); });
$("settings-theme").value = state.theme;
$("settings-theme").addEventListener("change", async () => {
state.theme = $("settings-theme").value;
await saveState();
applyTheme(state.theme);
});
$("settings-hide-low-holders").checked = state.hideLowHolderTokens; $("settings-hide-low-holders").checked = state.hideLowHolderTokens;
$("settings-hide-low-holders").addEventListener("change", async () => { $("settings-hide-low-holders").addEventListener("change", async () => {
state.hideLowHolderTokens = $("settings-hide-low-holders").checked; state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
@@ -241,6 +249,12 @@ function init(ctx) {
} }
}); });
$("settings-utc-timestamps").checked = state.utcTimestamps;
$("settings-utc-timestamps").addEventListener("change", async () => {
state.utcTimestamps = $("settings-utc-timestamps").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView); $("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener( $("btn-settings-add-token").addEventListener(

View File

@@ -73,7 +73,8 @@ function renderDropdown() {
function show() { function show() {
$("settings-addtoken-address").value = ""; $("settings-addtoken-address").value = "";
$("settings-addtoken-info").classList.add("hidden"); $("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden";
renderTop10(); renderTop10();
renderDropdown(); renderDropdown();
showView("settings-addtoken"); showView("settings-addtoken");
@@ -129,7 +130,7 @@ function init(_ctx) {
} }
const infoEl = $("settings-addtoken-info"); const infoEl = $("settings-addtoken-info");
infoEl.textContent = "Looking up token..."; infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden"); infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr); log.debugf("Looking up token contract", addr);
try { try {
const info = await lookupTokenInfo(addr, state.rpcUrl); const info = await lookupTokenInfo(addr, state.rpcUrl);
@@ -143,7 +144,8 @@ function init(_ctx) {
await saveState(); await saveState();
showFlash("Added " + info.symbol); showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = ""; $("settings-addtoken-address").value = "";
infoEl.classList.add("hidden"); infoEl.textContent = "";
infoEl.style.visibility = "hidden";
renderTop10(); renderTop10();
renderDropdown(); renderDropdown();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
@@ -151,7 +153,8 @@ function init(_ctx) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail); log.errorf("Token lookup failed for", addr, detail);
showFlash(detail); showFlash(detail);
infoEl.classList.add("hidden"); infoEl.textContent = "";
infoEl.style.visibility = "hidden";
} }
}); });
} }

View File

@@ -5,6 +5,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -12,6 +13,7 @@ const {
timeAgo, timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
@@ -25,6 +27,25 @@ const EXT_ICON =
let ctx; let ctx;
/**
* Determine a human-readable transaction type string from tx fields.
*/
function getTransactionType(tx) {
if (!tx.to) return "Contract Creation";
if (tx.direction === "contract") {
if (tx.directionLabel === "Swap") return "Swap";
if (
tx.method === "approve" ||
tx.directionLabel === "Approve" ||
tx.method === "setApprovalForAll"
)
return "Token Approval";
return "Contract Call";
}
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
return "Native ETH Transfer";
}
function copyableHtml(text, extraClass) { function copyableHtml(text, extraClass) {
const cls = const cls =
"underline decoration-dashed cursor-pointer" + "underline decoration-dashed cursor-pointer" +
@@ -98,6 +119,7 @@ function show(tx) {
direction: tx.direction || null, direction: tx.direction || null,
isContractCall: tx.isContractCall || false, isContractCall: tx.isContractCall || false,
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null,
}, },
}; };
render(); render();
@@ -134,30 +156,56 @@ function render() {
nativeEl.parentElement.classList.add("hidden"); nativeEl.parentElement.classList.add("hidden");
} }
// Show type label for contract interactions (Swap, Execute, etc.) // Always show transaction type as the first field
const typeSection = $("tx-detail-type-section"); const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type"); const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading"); const headingEl = $("tx-detail-heading");
if (tx.direction === "contract" && tx.directionLabel) { if (typeSection && typeEl) {
if (typeSection) { typeEl.textContent = getTransactionType(tx);
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden"); typeSection.classList.remove("hidden");
} }
} 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 // Token contract address (for ERC-20 transfers)
const tokenContractSection = $("tx-detail-token-contract-section");
const tokenContractEl = $("tx-detail-token-contract");
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = `https://etherscan.io/token/${tx.contractAddress}`;
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {
tokenContractSection.classList.add("hidden");
}
}
// Hide calldata and raw data sections; always fetch full tx details
const calldataSection = $("tx-detail-calldata-section"); const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden"); if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section"); const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden"); if (rawDataSection) rawDataSection.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") { // Hide on-chain detail sections (and their group wrapper) until populated
loadCalldata(tx.hash, tx.to); const onchainGroup = $("tx-detail-onchain-group");
if (onchainGroup) onchainGroup.classList.add("hidden");
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const el = $(id);
if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
@@ -171,11 +219,113 @@ function render() {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(el);
}; };
}); });
} }
async function loadCalldata(txHash, toAddress) { function showDetailField(sectionId, contentId, value) {
const section = $(sectionId);
const el = $(contentId);
if (!section || !el) return;
el.innerHTML = copyableHtml(value, "");
section.classList.remove("hidden");
}
function populateOnChainDetails(txData) {
// Block number
if (txData.block_number != null) {
const blockLink = `https://etherscan.io/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
blockEl.innerHTML =
copyableHtml(String(txData.block_number), "") +
etherscanLinkHtml(blockLink);
blockSection.classList.remove("hidden");
}
}
// Nonce
if (txData.nonce != null) {
showDetailField(
"tx-detail-nonce-section",
"tx-detail-nonce",
String(txData.nonce),
);
}
// Transaction fee
const feeWei = txData.fee?.value || txData.tx_fee;
if (feeWei) {
const feeEth = formatEther(String(feeWei));
showDetailField(
"tx-detail-fee-section",
"tx-detail-fee",
feeEth + " ETH",
);
}
// Gas price
const gasPrice = txData.gas_price;
if (gasPrice) {
const gwei = formatUnits(String(gasPrice), "gwei");
showDetailField(
"tx-detail-gasprice-section",
"tx-detail-gasprice",
gwei + " Gwei",
);
}
// Gas used
const gasUsed = txData.gas_used;
if (gasUsed) {
showDetailField(
"tx-detail-gasused-section",
"tx-detail-gasused",
String(gasUsed),
);
}
// Show the on-chain details group if any child section is visible
const onchainGroup = $("tx-detail-onchain-group");
if (onchainGroup) {
const hasVisible = [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
].some((id) => {
const el = $(id);
return el && !el.classList.contains("hidden");
});
if (hasVisible) {
onchainGroup.classList.remove("hidden");
}
}
// Bind copy handlers for newly added elements
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const section = $(id);
if (!section) continue;
section.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
}
async function loadFullTxDetails(txHash, toAddress, isContractCall) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -190,6 +340,10 @@ async function loadCalldata(txHash, toAddress) {
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null; const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return; if (!inputData || inputData === "0x") return;
@@ -248,6 +402,7 @@ async function loadCalldata(txHash, toAddress) {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(el);
}; };
}); });
} }

View File

@@ -4,6 +4,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -77,6 +78,7 @@ function attachCopyHandlers(viewId) {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(el);
}; };
}); });
} }

View File

@@ -23,8 +23,10 @@ const DEFAULT_STATE = {
hideFraudContracts: true, hideFraudContracts: true,
hideDustTransactions: true, hideDustTransactions: true,
dustThresholdGwei: 100000, dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system",
}; };
const state = { const state = {
@@ -53,8 +55,10 @@ async function saveState() {
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei, dustThresholdGwei: state.dustThresholdGwei,
utcTimestamps: state.utcTimestamps,
fraudContracts: state.fraudContracts, fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache, tokenHolderCache: state.tokenHolderCache,
theme: state.theme,
currentView: state.currentView, currentView: state.currentView,
selectedWallet: state.selectedWallet, selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress, selectedAddress: state.selectedAddress,
@@ -108,8 +112,11 @@ async function loadState() {
saved.dustThresholdGwei !== undefined saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei ? saved.dustThresholdGwei
: 100000; : 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || []; state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {}; state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.currentView = saved.currentView || null; state.currentView = saved.currentView || null;
state.selectedWallet = state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null; saved.selectedWallet !== undefined ? saved.selectedWallet : null;

View File

@@ -153,24 +153,38 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx // When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real // is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. A single transaction (e.g. a swap) can produce // amount and symbol. For contract calls (swaps), a single transaction
// multiple token transfers (one per token involved), so we key token // can produce multiple token transfers (input, intermediates, output).
// transfers by hash + contract address to keep all of them. We also // We consolidate these into the original tx entry using the token
// preserve contract-call metadata (direction, label, method) from the // transfer where the user *receives* tokens (the swap output), so
// matching normal tx so swaps display correctly. // the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) { for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower); const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash); const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") { if (existing && existing.direction === "contract") {
parsed.direction = "contract"; // For contract calls (swaps), consolidate into the original
parsed.directionLabel = existing.directionLabel; // tx entry. Prefer the "received" transfer (swap output)
parsed.isContractCall = true; // for the display amount. If no received transfer exists,
parsed.method = existing.method; // fall back to the first "sent" transfer (swap input).
// Remove the bare-hash normal tx so it doesn't appear as a const isReceived = parsed.direction === "received";
// duplicate with empty value; token transfers replace it. const needsAmount = !existing.exactValue;
txsByHash.delete(parsed.hash); if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
} }
// Use composite key so multiple token transfers per tx are kept. // Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || ""); const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed); txsByHash.set(ttKey, parsed);
} }

View File

@@ -359,9 +359,12 @@ function decode(data, toAddress) {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
if (s) { if (s) {
if (!inputToken) inputToken = s.tokenIn; if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn; if (!inputAmount) inputAmount = s.amountIn;
if (!minOutput) minOutput = s.amountOutMin; // Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
} }
} }
@@ -369,9 +372,9 @@ function decode(data, toAddress) {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
if (!inputToken) inputToken = s.tokenIn; if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn; if (!inputAmount) inputAmount = s.amountIn;
if (!minOutput) minOutput = s.amountOutMin; outputToken = s.tokenOut;
minOutput = s.amountOutMin;
} }
} }
@@ -388,12 +391,11 @@ function decode(data, toAddress) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) { if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn; if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn) if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn; inputAmount = v4.amountIn;
if (!minOutput && v4.amountOutMin) // Always update output: last swap step wins
minOutput = v4.amountOutMin; if (v4.tokenOut) outputToken = v4.tokenOut;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
} }
} }

View File

@@ -24,6 +24,26 @@ function hdWalletFromMnemonic(mnemonic) {
return { xpub, firstAddress }; 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) { function addressFromPrivateKey(key) {
const w = new Wallet(key); const w = new Wallet(key);
return w.address; return w.address;
@@ -38,6 +58,11 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
); );
return node.deriveChild(addrIndex); 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); return new Wallet(decryptedSecret);
} }
@@ -49,6 +74,8 @@ module.exports = {
generateMnemonic, generateMnemonic,
deriveAddressFromXpub, deriveAddressFromXpub,
hdWalletFromMnemonic, hdWalletFromMnemonic,
hdWalletFromXprv,
isValidXprv,
addressFromPrivateKey, addressFromPrivateKey,
getSignerForAddress, getSignerForAddress,
isValidMnemonic, isValidMnemonic,