Compare commits

...

23 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
22 changed files with 647 additions and 153 deletions

View File

@@ -437,6 +437,10 @@ transitions.
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
- **Elements**:
- "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"
- Time: ISO datetime + relative age in parentheses
- Amount: value + symbol (bold)
@@ -445,6 +449,11 @@ transitions.
- To: blockie + color dot + full address (tap to copy) + etherscan link
- ENS name if available
- 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**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**

View File

@@ -107,7 +107,8 @@
</div>
<div
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 can take your funds; if you lose them, your wallet
@@ -184,7 +185,7 @@
<!-- active address headline -->
<div
id="total-value"
class="text-2xl font-bold min-h-[2rem]"
class="text-2xl font-bold min-h-[2rem] text-fg"
></div>
<div
id="total-value-sub"
@@ -375,7 +376,8 @@
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 hidden"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
@@ -579,13 +581,17 @@
<div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></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">
Estimated network fee
</div>
<div id="confirm-fee-amount" class="text-xs"></div>
</div>
<div id="confirm-warnings" class="mb-2 hidden"></div>
<div
id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-warning"
class="mb-2"
@@ -601,7 +607,8 @@
</div>
<div
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 class="mb-2">
<label class="block mb-1 text-xs">Password</label>
@@ -614,6 +621,7 @@
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-confirm-send"
@@ -728,7 +736,8 @@
</button>
<div
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>
@@ -756,7 +765,8 @@
</div>
<div
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 class="mb-2">
<label class="block mb-1 text-xs text-muted"
@@ -814,7 +824,7 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer"
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input
type="checkbox"
@@ -822,6 +832,17 @@
/>
Show tracked tokens with zero balance
</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 class="bg-well p-3 mx-1 mb-3">
@@ -903,6 +924,12 @@
/>
<span class="text-xs text-muted">gwei</span>
</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 class="bg-well p-3 mx-1 mb-3">
@@ -938,7 +965,8 @@
</p>
<div
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 class="mb-2">
<label class="block mb-1">Password</label>
@@ -1013,7 +1041,8 @@
/>
<div
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>
<button
id="btn-settings-addtoken-manual"
@@ -1035,38 +1064,82 @@
<h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction
</h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div id="tx-detail-type" class="text-xs font-bold"></div>
<!-- ── Identity ── -->
<div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">
Transaction hash
</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 id="tx-detail-status" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="mb-1">
<div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></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 id="tx-detail-value" class="text-xs"></div>
</div>
<div class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Native quantity</div>
<div class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Native quantity
</div>
<div id="tx-detail-native" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">From</div>
<div id="tx-detail-from" class="text-xs break-all"></div>
<div
id="tx-detail-token-contract-section"
class="mb-1 hidden"
>
<div class="text-xs text-muted mb-1">
Token contract
</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 id="tx-detail-to" class="text-xs break-all"></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
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
@@ -1079,11 +1152,40 @@
></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>
<!-- ── 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 class="tx-detail-group">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
@@ -1091,6 +1193,7 @@
></div>
</div>
</div>
</div>
<!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden">
@@ -1139,7 +1242,8 @@
</div>
<div
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 class="flex justify-between">
<button
@@ -1167,8 +1271,10 @@
<div
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="
visibility: hidden;
min-height: 1.25rem;
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
@@ -1205,7 +1311,8 @@
</div>
<div
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 class="flex justify-between">
<button

View File

@@ -6,6 +6,7 @@ const { state, saveState, loadState } = require("../shared/state");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { $, showView } = require("./views/helpers");
const { applyTheme } = require("./theme");
const home = require("./views/home");
const welcome = require("./views/welcome");
@@ -176,6 +177,7 @@ async function init() {
}
await loadState();
applyTheme(state.theme);
// Auto-default active address
if (

View File

@@ -15,7 +15,40 @@
--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 {
width: 396px;
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() {
$("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");
list.innerHTML = getTopTokens(25)
.map(
@@ -45,7 +46,7 @@ function init(ctx) {
}
const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
@@ -63,7 +64,8 @@ function init(ctx) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
});

View File

@@ -11,6 +11,25 @@ const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
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"];
@@ -52,7 +71,7 @@ function show() {
$("import-xprv-key").value = "";
$("add-wallet-password").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");
}
@@ -97,18 +116,18 @@ async function importMnemonic(ctx) {
const pw = validatePassword();
if (!pw) return;
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
);
if (duplicate) {
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash(
"This recovery phrase is already added (" + duplicate.name + ").",
"This recovery phrase is already added (" + xpubDup.name + ").",
);
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
@@ -162,15 +181,10 @@ async function importPrivateKey(ctx) {
}
const pw = validatePassword();
if (!pw) return;
const duplicate = state.wallets.find(
(w) =>
w.type === "key" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() === addr.toLowerCase(),
);
const duplicate = findWalletByAddress(addr);
if (duplicate) {
showFlash(
"This private key is already added (" + duplicate.name + ").",
"This address already exists in wallet (" + duplicate.name + ").",
);
return;
}
@@ -208,14 +222,14 @@ async function importXprvKey(ctx) {
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 + ").");
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();
@@ -267,7 +281,7 @@ function init(ctx) {
// Generate mnemonic
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden");
$("add-wallet-phrase-warning").style.visibility = "visible";
});
// Import / confirm

View File

@@ -2,6 +2,7 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
@@ -94,18 +95,39 @@ function show() {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
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 (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
pad(d.getSeconds()) +
tzStr
);
}
@@ -241,6 +263,7 @@ function init(_ctx) {
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-full"));
}
});
@@ -310,8 +333,8 @@ function init(_ctx) {
$("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = "";
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
@@ -322,7 +345,7 @@ function init(_ctx) {
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").classList.remove("hidden");
$("export-privkey-flash").style.visibility = "visible";
return;
}
const btn = $("btn-export-privkey-confirm");
@@ -343,10 +366,10 @@ function init(_ctx) {
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").style.visibility = "hidden";
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden");
$("export-privkey-flash").style.visibility = "visible";
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
@@ -358,6 +381,7 @@ function init(_ctx) {
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
}
});
@@ -366,6 +390,7 @@ function init(_ctx) {
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-address"));
}
});

View File

@@ -5,6 +5,7 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle,
escapeHtml,
@@ -47,18 +48,39 @@ function etherscanAddressLink(address) {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
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 (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
pad(d.getSeconds()) +
tzStr
);
}
@@ -317,6 +339,7 @@ function init(_ctx) {
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-token-full"));
}
});
@@ -325,6 +348,7 @@ function init(_ctx) {
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
}
});
@@ -373,6 +397,7 @@ function init(_ctx) {
copyEl.addEventListener("click", () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
});
}
updateSendBalance();

View File

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

View File

@@ -15,6 +15,7 @@ const {
hideError,
showView,
showFlash,
flashCopyFeedback,
addressTitle,
addressDotHtml,
escapeHtml,
@@ -117,6 +118,7 @@ function show(txInfo) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
};
}
} 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>`,
)
.join("");
warningsEl.classList.remove("hidden");
warningsEl.style.visibility = "visible";
} else {
warningsEl.classList.add("hidden");
warningsEl.innerHTML = "";
warningsEl.style.visibility = "hidden";
}
// Check for errors
@@ -224,11 +227,12 @@ function show(txInfo) {
errorsEl.innerHTML = errors
.map((e) => `<div class="text-xs">${e}</div>`)
.join("");
errorsEl.classList.remove("hidden");
errorsEl.style.visibility = "visible";
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
} else {
errorsEl.classList.add("hidden");
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
}
@@ -238,7 +242,7 @@ function show(txInfo) {
hideError("confirm-tx-password-error");
// Gas estimate — show placeholder then fetch async
$("confirm-fee").classList.remove("hidden");
$("confirm-fee").style.visibility = "visible";
$("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");

View File

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

View File

@@ -40,11 +40,13 @@ function $(id) {
function showError(id, msg) {
const el = $(id);
el.textContent = msg;
el.classList.remove("hidden");
el.style.visibility = "visible";
}
function hideError(id) {
$(id).classList.add("hidden");
const el = $(id);
el.textContent = "";
el.style.visibility = "hidden";
}
function showView(name) {
@@ -226,18 +228,39 @@ function formatAddressHtml(address, ensName, maxLen, title) {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
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 (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
pad(d.getSeconds()) +
tzStr
);
}
@@ -258,12 +281,26 @@ function timeAgo(timestamp) {
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 = {
$,
showError,
hideError,
showView,
showFlash,
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
addressColor,

View File

@@ -2,6 +2,7 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
isoDate,
timeAgo,
@@ -85,9 +86,10 @@ function renderActiveAddress() {
el.innerHTML =
`<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>`;
$("active-addr-copy").addEventListener("click", () => {
$("active-addr-copy").addEventListener("click", (e) => {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
});
} else {
el.textContent = "";

View File

@@ -2,6 +2,7 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
formatAddressHtml,
addressTitle,
} = require("./helpers");
@@ -52,19 +53,21 @@ function show() {
"This is an ERC-20 token. Only send " +
symbol +
" 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 {
warningEl.classList.add("hidden");
warningEl.textContent = "";
warningEl.style.visibility = "hidden";
}
showView("receive");
}
function init(ctx) {
$("receive-address-block").addEventListener("click", () => {
$("receive-address-block").addEventListener("click", (e) => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}
});
@@ -73,6 +76,7 @@ function init(ctx) {
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("receive-address-block"));
}
});

View File

@@ -1,4 +1,5 @@
const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { applyTheme } = require("../theme");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
@@ -214,6 +215,13 @@ function init(ctx) {
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").addEventListener("change", async () => {
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-settings-add-token").addEventListener(

View File

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

View File

@@ -5,6 +5,7 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle,
escapeHtml,
@@ -12,6 +13,7 @@ const {
timeAgo,
} = require("./helpers");
const { state } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
@@ -25,6 +27,25 @@ const EXT_ICON =
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) {
const cls =
"underline decoration-dashed cursor-pointer" +
@@ -98,6 +119,7 @@ function show(tx) {
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
contractAddress: tx.contractAddress || null,
},
};
render();
@@ -134,30 +156,56 @@ function render() {
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 typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
if (typeSection && typeEl) {
typeEl.textContent = getTransactionType(tx);
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
// 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");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
// Hide on-chain detail sections (and their group wrapper) until populated
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);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
@@ -171,11 +219,113 @@ function render() {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
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 actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -190,6 +340,10 @@ async function loadCalldata(txHash, toAddress) {
);
if (!resp.ok) return;
const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
@@ -248,6 +402,7 @@ async function loadCalldata(txHash, toAddress) {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}

View File

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

View File

@@ -23,8 +23,10 @@ const DEFAULT_STATE = {
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
};
const state = {
@@ -53,8 +55,10 @@ async function saveState() {
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei,
utcTimestamps: state.utcTimestamps,
fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache,
theme: state.theme,
currentView: state.currentView,
selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress,
@@ -108,8 +112,11 @@ async function loadState() {
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.currentView = saved.currentView || null;
state.selectedWallet =
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
// 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
// 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.
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// 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 || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
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 || "");
txsByHash.set(ttKey, parsed);
}

View File

@@ -359,9 +359,12 @@ function decode(data, toAddress) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
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]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
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]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
if (!minOutput && v4.amountOutMin)
minOutput = v4.amountOutMin;
// Always update output: last swap step wins
if (v4.tokenOut) outputToken = v4.tokenOut;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
}
}