Compare commits

..

3 Commits

Author SHA1 Message Date
user
76320d1e1b refactor: unify add-wallet, import-key, and import-xprv into single view
All checks were successful
check / check (push) Successful in 22s
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 12:30:39 -08:00
user
304fbaf8f0 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 12:30:39 -08:00
user
dac8d43b21 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 12:30:39 -08:00
23 changed files with 257 additions and 1019 deletions

View File

@@ -437,10 +437,6 @@ 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)
@@ -449,11 +445,6 @@ 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

@@ -58,27 +58,24 @@
<h2 class="font-bold mb-2">Add Wallet</h2> <h2 class="font-bold mb-2">Add Wallet</h2>
<!-- Mode selector tabs --> <!-- Mode selector tabs -->
<div <div class="flex gap-1 mb-3">
class="flex border-b border-border mb-3"
id="add-wallet-tabs"
>
<button <button
id="tab-mnemonic" 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" class="border border-border px-2 py-1 cursor-pointer text-xs hover:bg-fg hover:text-bg bg-fg text-bg"
> >
From Phrase Recovery Phrase
</button> </button>
<button <button
id="tab-privkey" 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" class="border border-border px-2 py-1 cursor-pointer text-xs hover:bg-fg hover:text-bg"
> >
From Key Private Key
</button> </button>
<button <button
id="tab-xprv" 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" class="border border-border px-2 py-1 cursor-pointer text-xs hover:bg-fg hover:text-bg"
> >
From xprv Extended Key (xprv)
</button> </button>
</div> </div>
@@ -107,8 +104,7 @@
</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" class="text-xs mb-2 border border-border border-dashed p-2 hidden"
style="visibility: hidden"
> >
Write these words down and keep them safe. Anyone with Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet them can take your funds; if you lose them, your wallet
@@ -185,7 +181,7 @@
<!-- active address headline --> <!-- active address headline -->
<div <div
id="total-value" id="total-value"
class="text-2xl font-bold min-h-[2rem] text-fg" class="text-2xl font-bold min-h-[2rem]"
></div> ></div>
<div <div
id="total-value-sub" id="total-value-sub"
@@ -376,8 +372,7 @@
</p> </p>
<div <div
id="export-privkey-flash" id="export-privkey-flash"
class="text-xs mb-2 min-h-[1.25rem]" class="text-xs mb-2 hidden"
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>
@@ -507,11 +502,6 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Address (0x...) or ENS name" placeholder="Address (0x...) or ENS name"
/> />
<div
id="send-to-error"
class="text-xs"
style="min-height: 1.25rem; color: #cc0000"
></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<div class="flex justify-between mb-1"> <div class="flex justify-between mb-1">
@@ -581,53 +571,22 @@
<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" style="visibility: hidden"> <div id="confirm-fee" class="mb-3 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 <div id="confirm-warnings" class="mb-2 hidden"></div>
id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</div>
</div>
<div <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2" class="mb-2 border border-border border-dashed p-2 hidden"
style="visibility: hidden; min-height: 1.25rem"
></div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
type="password"
id="confirm-tx-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-confirm-send" id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Sign &amp; Send Send
</button> </button>
</div> </div>
@@ -706,6 +665,42 @@
</button> </button>
</div> </div>
<!-- ============ PASSWORD MODAL ============ -->
<div
id="password-modal"
class="hidden fixed inset-0 bg-bg flex items-center justify-center z-50"
>
<div class="border border-border p-4 bg-bg w-80">
<h2 class="font-bold mb-2">Enter Password</h2>
<p class="text-xs text-muted mb-2">
Your password is needed to authorize this transaction.
</p>
<input
type="password"
id="modal-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg mb-2"
/>
<div
id="modal-password-error"
class="text-xs mb-2 border border-border border-dashed p-1 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-modal-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm
</button>
<button
id="btn-modal-cancel"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
</div>
</div>
<!-- ============ RECEIVE ============ --> <!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden"> <div id="view-receive" class="view hidden">
<button <button
@@ -736,8 +731,7 @@
</button> </button>
<div <div
id="receive-erc20-warning" id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3" class="text-xs border border-border border-dashed p-2 mt-3 hidden"
style="visibility: hidden"
></div> ></div>
</div> </div>
@@ -765,8 +759,7 @@
</div> </div>
<div <div
id="add-token-info" id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem]" class="text-xs text-muted mb-2 hidden"
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"
@@ -824,7 +817,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 mb-2" class="text-xs flex items-center gap-1 cursor-pointer"
> >
<input <input
type="checkbox" type="checkbox"
@@ -832,17 +825,6 @@
/> />
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">
@@ -924,12 +906,6 @@
/> />
<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">
@@ -965,8 +941,7 @@
</p> </p>
<div <div
id="delete-wallet-flash" id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]" class="text-xs text-red-500 mb-2 hidden"
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>
@@ -1041,8 +1016,7 @@
/> />
<div <div
id="settings-addtoken-info" id="settings-addtoken-info"
class="text-xs text-muted mt-1 min-h-[1.25rem]" class="text-xs text-muted mt-1 hidden"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-settings-addtoken-manual" id="btn-settings-addtoken-manual"
@@ -1064,134 +1038,60 @@
<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">
<!-- ── Identity ── --> <div class="text-xs text-muted mb-1">Type</div>
<div class="tx-detail-group mb-1"> <div id="tx-detail-type" class="text-xs font-bold"></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">
Transaction hash
</div>
<div
id="tx-detail-hash"
class="text-xs break-all"
></div>
</div>
<div id="tx-detail-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-1">
<div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></div>
</div>
</div> </div>
<div class="mb-4">
<!-- ── Value ── --> <div class="text-xs text-muted mb-1">Status</div>
<div class="tx-detail-group mb-1"> <div id="tx-detail-status" class="text-xs"></div>
<div class="mb-3"> </div>
<div class="text-xs text-muted mb-1">Amount</div> <div class="mb-4">
<div id="tx-detail-value" class="text-xs"></div> <div class="text-xs text-muted mb-1">Time</div>
</div> <div id="tx-detail-time" class="text-xs"></div>
<div class="mb-3 hidden"> </div>
<div class="text-xs text-muted mb-1"> <div class="mb-4">
Native quantity <div class="text-xs text-muted mb-1">Amount</div>
</div> <div id="tx-detail-value" class="text-xs"></div>
<div id="tx-detail-native" class="text-xs"></div> </div>
</div> <div class="mb-4 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>
<div class="mb-4">
<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 <div
id="tx-detail-token-contract-section" id="tx-detail-calldata-well"
class="mb-1 hidden" class="mb-3 border border-border border-dashed p-2"
> >
<div class="text-xs text-muted mb-1"> <div class="text-xs text-muted mb-1">Action</div>
Token contract
</div>
<div <div
id="tx-detail-token-contract" id="tx-detail-calldata-action"
class="text-xs break-all" class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div> ></div>
</div> </div>
</div> </div>
<div class="mb-4">
<!-- ── Parties ── --> <div class="text-xs text-muted mb-1">Transaction hash</div>
<div class="tx-detail-group mb-1"> <div id="tx-detail-hash" class="text-xs break-all"></div>
<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> </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="border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</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" class="text-xs break-all font-mono border border-border border-dashed p-2"
class="text-xs break-all font-mono border border-border border-dashed p-2" ></div>
></div>
</div>
</div> </div>
</div> </div>
@@ -1240,11 +1140,7 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<div <div id="approve-tx-error" class="text-xs hidden mb-2"></div>
id="approve-tx-error"
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"> <div class="flex justify-between">
<button <button
id="btn-approve-tx" id="btn-approve-tx"
@@ -1271,10 +1167,8 @@
<div <div
id="approve-sign-danger-warning" id="approve-sign-danger-warning"
class="mb-3 p-2 text-xs font-bold" class="hidden 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;
@@ -1309,11 +1203,7 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<div <div id="approve-sign-error" class="text-xs hidden mb-2"></div>
id="approve-sign-error"
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"> <div class="flex justify-between">
<button <button
id="btn-approve-sign" id="btn-approve-sign"

View File

@@ -6,7 +6,6 @@ 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");
@@ -73,7 +72,6 @@ const RESTORABLE_VIEWS = new Set([
"receive", "receive",
"settings", "settings",
"settings-addtoken", "settings-addtoken",
"confirm-tx",
"transaction", "transaction",
"success-tx", "success-tx",
"error-tx", "error-tx",
@@ -127,13 +125,6 @@ function restoreView() {
case "settings-addtoken": case "settings-addtoken":
settingsAddToken.show(); settingsAddToken.show();
break; break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView();
}
break;
case "transaction": case "transaction":
if (state.viewData && state.viewData.tx) { if (state.viewData && state.viewData.tx) {
transactionDetail.render(); transactionDetail.render();
@@ -177,7 +168,6 @@ async function init() {
} }
await loadState(); await loadState();
applyTheme(state.theme);
// Auto-default active address // Auto-default active address
if ( if (

View File

@@ -15,40 +15,7 @@
--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;
}

View File

@@ -1,33 +0,0 @@
// 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,8 +7,7 @@ const { log } = require("../../shared/log");
function show() { function show() {
$("add-token-address").value = ""; $("add-token-address").value = "";
$("add-token-info").textContent = ""; $("add-token-info").classList.add("hidden");
$("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(
@@ -46,7 +45,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.style.visibility = "visible"; infoEl.classList.remove("hidden");
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);
@@ -64,8 +63,7 @@ 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.textContent = ""; infoEl.classList.add("hidden");
infoEl.style.visibility = "hidden";
} }
}); });

View File

@@ -11,25 +11,6 @@ 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"; let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"]; const MODES = ["mnemonic", "privkey", "xprv"];
@@ -46,21 +27,8 @@ function switchMode(mode) {
currentMode = mode; currentMode = mode;
for (const m of MODES) { for (const m of MODES) {
$("add-wallet-section-" + m).classList.toggle("hidden", m !== mode); $("add-wallet-section-" + m).classList.toggle("hidden", m !== mode);
const tab = $("tab-" + m); $("tab-" + m).classList.toggle("bg-fg", m === mode);
const isActive = m === mode; $("tab-" + m).classList.toggle("text-bg", 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]; $("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
} }
@@ -71,7 +39,7 @@ function show() {
$("import-xprv-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").style.visibility = "hidden"; $("add-wallet-phrase-warning").classList.add("hidden");
switchMode("mnemonic"); switchMode("mnemonic");
showView("add-wallet"); showView("add-wallet");
} }
@@ -116,18 +84,18 @@ async function importMnemonic(ctx) {
const pw = validatePassword(); const pw = validatePassword();
if (!pw) return; if (!pw) return;
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const xpubDup = findWalletByXpub(xpub); const duplicate = state.wallets.find(
if (xpubDup) { (w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash( showFlash(
"This recovery phrase is already added (" + xpubDup.name + ").", "This recovery phrase is already added (" + 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 = {
@@ -181,10 +149,15 @@ async function importPrivateKey(ctx) {
} }
const pw = validatePassword(); const pw = validatePassword();
if (!pw) return; if (!pw) return;
const duplicate = findWalletByAddress(addr); const duplicate = state.wallets.find(
(w) =>
w.type === "key" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() === addr.toLowerCase(),
);
if (duplicate) { if (duplicate) {
showFlash( showFlash(
"This address already exists in wallet (" + duplicate.name + ").", "This private key is already added (" + duplicate.name + ").",
); );
return; return;
} }
@@ -222,14 +195,14 @@ async function importXprvKey(ctx) {
return; return;
} }
const { xpub, firstAddress } = result; const { xpub, firstAddress } = result;
const xpubDup = findWalletByXpub(xpub); const duplicate = state.wallets.find(
if (xpubDup) { (w) =>
showFlash("This key is already added (" + xpubDup.name + ")."); (w.type === "hd" || w.type === "xprv") &&
return; w.addresses[0] &&
} w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
const addrDup = findWalletByAddress(firstAddress); );
if (addrDup) { if (duplicate) {
showFlash("Address already exists in wallet (" + addrDup.name + ")."); showFlash("This key is already added (" + duplicate.name + ").");
return; return;
} }
const pw = validatePassword(); const pw = validatePassword();
@@ -281,7 +254,7 @@ function init(ctx) {
// Generate mnemonic // Generate mnemonic
$("btn-generate-phrase").addEventListener("click", () => { $("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic(); $("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").style.visibility = "visible"; $("add-wallet-phrase-warning").classList.remove("hidden");
}); });
// Import / confirm // Import / confirm

View File

@@ -2,7 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
@@ -16,11 +15,7 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { const { updateSendBalance, renderSendTokenSelect } = require("./send");
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -95,39 +90,18 @@ 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
); );
} }
@@ -263,7 +237,6 @@ function init(_ctx) {
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("address-full"));
} }
}); });
@@ -286,7 +259,6 @@ function init(_ctx) {
$("send-token").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
updateSendBalance(); updateSendBalance();
resetSendValidation();
showView("send"); showView("send");
}); });
@@ -333,8 +305,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 = "";
@@ -345,12 +317,9 @@ 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").style.visibility = "visible"; $("export-privkey-flash").classList.remove("hidden");
return; return;
} }
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
try { try {
const secret = await decryptWithPassword( const secret = await decryptWithPassword(
@@ -366,13 +335,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").style.visibility = "hidden"; $("export-privkey-flash").classList.add("hidden");
} catch { } catch {
$("export-privkey-flash").textContent = "Wrong password."; $("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").style.visibility = "visible"; $("export-privkey-flash").classList.remove("hidden");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
} }
}); });
@@ -381,7 +347,6 @@ function init(_ctx) {
if (key) { if (key) {
navigator.clipboard.writeText(key); navigator.clipboard.writeText(key);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
} }
}); });
@@ -390,7 +355,6 @@ 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,7 +5,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -24,11 +23,7 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { const { updateSendBalance, renderSendTokenSelect } = require("./send");
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
@@ -48,39 +43,18 @@ 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
); );
} }
@@ -339,7 +313,6 @@ function init(_ctx) {
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("address-token-full"));
} }
}); });
@@ -348,7 +321,6 @@ 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);
} }
}); });
@@ -397,11 +369,9 @@ 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();
resetSendValidation();
showView("send"); showView("send");
}); });

View File

@@ -4,8 +4,6 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
showView, showView,
showError,
hideError,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers"); const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
@@ -172,8 +170,6 @@ function showTxApproval(details) {
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(details.txParams.data, toAddr || ""); const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) { if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
for (const d of decoded.details) { for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) { if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address; pendingTxDetails.to = d.address;
@@ -181,20 +177,10 @@ function showTxApproval(details) {
if (d.label === "Amount") { if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value; pendingTxDetails.amount = d.rawValue || d.value;
} }
if (d.label === "Token In" && d.isToken && d.address) {
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
if (t) {
decodedTokenAddr = d.address;
decodedTokenSymbol = t.symbol;
}
}
} }
if (token) { if (token) {
pendingTxDetails.token = toAddr; pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol; pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddr) {
pendingTxDetails.token = decodedTokenAddr;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
} }
} }
@@ -268,9 +254,6 @@ function showTxApproval(details) {
$("approve-tx-data-section").classList.add("hidden"); $("approve-tx-data-section").classList.add("hidden");
} }
$("approve-tx-password").value = "";
hideError("approve-tx-error");
showView("approve-tx"); showView("approve-tx");
} }
@@ -351,15 +334,15 @@ function showSignApproval(details) {
if (warningEl) { if (warningEl) {
if (sp.dangerWarning) { if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning; warningEl.textContent = sp.dangerWarning;
warningEl.style.visibility = "visible"; warningEl.classList.remove("hidden");
} else { } else {
warningEl.textContent = ""; warningEl.textContent = "";
warningEl.style.visibility = "hidden"; warningEl.classList.add("hidden");
} }
} }
$("approve-sign-password").value = ""; $("approve-sign-password").value = "";
hideError("approve-sign-error"); $("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = false; $("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted"); $("btn-approve-sign").classList.remove("text-muted");
@@ -424,10 +407,11 @@ function init(ctx) {
$("btn-approve-tx").addEventListener("click", () => { $("btn-approve-tx").addEventListener("click", () => {
const password = $("approve-tx-password").value; const password = $("approve-tx-password").value;
if (!password) { if (!password) {
showError("approve-tx-error", "Please enter your password."); $("approve-tx-error").textContent = "Please enter your password.";
$("approve-tx-error").classList.remove("hidden");
return; return;
} }
hideError("approve-tx-error"); $("approve-tx-error").classList.add("hidden");
$("btn-approve-tx").disabled = true; $("btn-approve-tx").disabled = true;
$("btn-approve-tx").classList.add("text-muted"); $("btn-approve-tx").classList.add("text-muted");
@@ -463,10 +447,11 @@ function init(ctx) {
$("btn-approve-sign").addEventListener("click", () => { $("btn-approve-sign").addEventListener("click", () => {
const password = $("approve-sign-password").value; const password = $("approve-sign-password").value;
if (!password) { if (!password) {
showError("approve-sign-error", "Please enter your password."); $("approve-sign-error").textContent = "Please enter your password.";
$("approve-sign-error").classList.remove("hidden");
return; return;
} }
hideError("approve-sign-error"); $("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = true; $("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted"); $("btn-approve-sign").classList.add("text-muted");
@@ -484,7 +469,8 @@ function init(ctx) {
} else { } else {
const msg = const msg =
(response && response.error) || "Signing failed."; (response && response.error) || "Signing failed.";
showError("approve-sign-error", msg); $("approve-sign-error").textContent = msg;
$("approve-sign-error").classList.remove("hidden");
$("btn-approve-sign").disabled = false; $("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted"); $("btn-approve-sign").classList.remove("text-muted");
} }

View File

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

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").style.visibility = "hidden"; $("delete-wallet-flash").classList.add("hidden");
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
@@ -29,21 +29,17 @@ 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").style.visibility = "visible"; $("delete-wallet-flash").classList.remove("hidden");
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").style.visibility = "visible"; $("delete-wallet-flash").classList.remove("hidden");
return; return;
} }
const btn = $("btn-delete-wallet-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const walletIdx = deleteWalletIndex; const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
@@ -52,9 +48,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").style.visibility = "visible"; $("delete-wallet-flash").classList.remove("hidden");
btn.disabled = false;
btn.classList.remove("text-muted");
return; return;
} }

View File

@@ -40,13 +40,11 @@ function $(id) {
function showError(id, msg) { function showError(id, msg) {
const el = $(id); const el = $(id);
el.textContent = msg; el.textContent = msg;
el.style.visibility = "visible"; el.classList.remove("hidden");
} }
function hideError(id) { function hideError(id) {
const el = $(id); $(id).classList.add("hidden");
el.textContent = "";
el.style.visibility = "hidden";
} }
function showView(name) { function showView(name) {
@@ -228,39 +226,18 @@ 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
); );
} }
@@ -281,26 +258,12 @@ 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,7 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
isoDate, isoDate,
timeAgo, timeAgo,
@@ -12,11 +11,7 @@ const {
truncateMiddle, truncateMiddle,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const { state, saveState, currentAddress } = require("../../shared/state");
const { const { updateSendBalance, renderSendTokenSelect } = require("./send");
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { const {
formatUsd, formatUsd,
@@ -86,10 +81,9 @@ 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", (e) => { $("active-addr-copy").addEventListener("click", () => {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}); });
} else { } else {
el.textContent = ""; el.textContent = "";
@@ -394,7 +388,6 @@ function init(ctx) {
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
updateSendBalance(); updateSendBalance();
resetSendValidation();
showView("send"); showView("send");
}); });

View File

@@ -2,7 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
formatAddressHtml, formatAddressHtml,
addressTitle, addressTitle,
} = require("./helpers"); } = require("./helpers");
@@ -53,21 +52,19 @@ 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.style.visibility = "visible"; warningEl.classList.remove("hidden");
} else { } else {
warningEl.textContent = ""; warningEl.classList.add("hidden");
warningEl.style.visibility = "hidden";
} }
showView("receive"); showView("receive");
} }
function init(ctx) { function init(ctx) {
$("receive-address-block").addEventListener("click", (e) => { $("receive-address-block").addEventListener("click", () => {
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);
} }
}); });
@@ -76,7 +73,6 @@ 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

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

View File

@@ -1,5 +1,4 @@
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");
@@ -215,13 +214,6 @@ 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;
@@ -249,12 +241,6 @@ 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,8 +73,7 @@ function renderDropdown() {
function show() { function show() {
$("settings-addtoken-address").value = ""; $("settings-addtoken-address").value = "";
$("settings-addtoken-info").textContent = ""; $("settings-addtoken-info").classList.add("hidden");
$("settings-addtoken-info").style.visibility = "hidden";
renderTop10(); renderTop10();
renderDropdown(); renderDropdown();
showView("settings-addtoken"); showView("settings-addtoken");
@@ -130,7 +129,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.style.visibility = "visible"; infoEl.classList.remove("hidden");
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);
@@ -144,8 +143,7 @@ function init(_ctx) {
await saveState(); await saveState();
showFlash("Added " + info.symbol); showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = ""; $("settings-addtoken-address").value = "";
infoEl.textContent = ""; infoEl.classList.add("hidden");
infoEl.style.visibility = "hidden";
renderTop10(); renderTop10();
renderDropdown(); renderDropdown();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
@@ -153,8 +151,7 @@ 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.textContent = ""; infoEl.classList.add("hidden");
infoEl.style.visibility = "hidden";
} }
}); });
} }

View File

@@ -5,7 +5,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -13,7 +12,6 @@ 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");
@@ -27,25 +25,6 @@ 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" +
@@ -119,7 +98,6 @@ 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();
@@ -156,59 +134,32 @@ function render() {
nativeEl.parentElement.classList.add("hidden"); nativeEl.parentElement.classList.add("hidden");
} }
// Always show transaction type as the first field // Show type label for contract interactions (Swap, Execute, etc.)
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 (typeSection && typeEl) { if (tx.direction === "contract" && tx.directionLabel) {
typeEl.textContent = getTransactionType(tx); if (typeSection) {
typeSection.classList.remove("hidden"); typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
} }
if (headingEl) headingEl.textContent = "Transaction"; if (headingEl) headingEl.textContent = "Transaction";
// Token contract address (for ERC-20 transfers) // Hide calldata and raw data sections; re-fetch if this is a contract call
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");
// Hide on-chain detail sections (and their group wrapper) until populated if (tx.isContractCall || tx.direction === "contract") {
const onchainGroup = $("tx-detail-onchain-group"); loadCalldata(tx.hash, tx.to);
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); $("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
@@ -219,113 +170,11 @@ function render() {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(el);
}; };
}); });
} }
function showDetailField(sectionId, contentId, value) { async function loadCalldata(txHash, toAddress) {
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");
@@ -340,10 +189,6 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
); );
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;
@@ -402,7 +247,6 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
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,7 +4,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -44,11 +43,10 @@ function toAddressHtml(address) {
if (title) { if (title) {
return ( return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` + `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` + `<div class="break-all">${escapeHtml(address)}${extLink}</div>`
extLink
); );
} }
return `<div class="flex items-center">${dot}<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`; return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
} }
function txHashHtml(hash) { function txHashHtml(hash) {
@@ -60,16 +58,6 @@ function txHashHtml(hash) {
); );
} }
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = `https://etherscan.io/block/${num}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
extLink
);
}
function attachCopyHandlers(viewId) { function attachCopyHandlers(viewId) {
document document
.getElementById(viewId) .getElementById(viewId)
@@ -78,7 +66,6 @@ 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);
}; };
}); });
} }
@@ -152,7 +139,7 @@ function etherscanTokenLink(address) {
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = "";
if (decoded.name) { if (decoded.name) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`; html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`; html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
@@ -177,36 +164,20 @@ function decodedDetailsHtml(decoded) {
} }
html += `</div>`; html += `</div>`;
} }
html += `</div>`;
return html; return html;
} }
function renderSuccess() { function renderSuccess() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.hash) return; if (!d || !d.hash) return;
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
const hasDecoded = d.decoded && d.decoded.details; $("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber);
// When decoded details are present, the Amount and To are already
// shown inside the decoded well — hide the top-level duplicates.
const summarySection = $("success-tx-summary").parentElement;
const toSection = $("success-tx-to").parentElement;
if (hasDecoded) {
summarySection.classList.add("hidden");
toSection.classList.add("hidden");
} else {
summarySection.classList.remove("hidden");
toSection.classList.remove("hidden");
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
$("success-tx-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash); $("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present // Show decoded calldata details if present
const decodedEl = $("success-tx-decoded"); const decodedEl = $("success-tx-decoded");
if (decodedEl && hasDecoded) { if (decodedEl && d.decoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded); decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden"); decodedEl.classList.remove("hidden");
} else if (decodedEl) { } else if (decodedEl) {

View File

@@ -23,10 +23,8 @@ 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 = {
@@ -55,10 +53,8 @@ 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,
@@ -112,11 +108,8 @@ 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,38 +153,24 @@ 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. For contract calls (swaps), a single transaction // amount and symbol. A single transaction (e.g. a swap) can produce
// can produce multiple token transfers (input, intermediates, output). // multiple token transfers (one per token involved), so we key token
// We consolidate these into the original tx entry using the token // transfers by hash + contract address to keep all of them. We also
// transfer where the user *receives* tokens (the swap output), so // preserve contract-call metadata (direction, label, method) from the
// the transaction list shows the final result rather than confusing // matching normal tx so swaps display correctly.
// 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") {
// For contract calls (swaps), consolidate into the original parsed.direction = "contract";
// tx entry. Prefer the "received" transfer (swap output) parsed.directionLabel = existing.directionLabel;
// for the display amount. If no received transfer exists, parsed.isContractCall = true;
// fall back to the first "sent" transfer (swap input). parsed.method = existing.method;
const isReceived = parsed.direction === "received"; // Remove the bare-hash normal tx so it doesn't appear as a
const needsAmount = !existing.exactValue; // duplicate with empty value; token transfers replace it.
if (isReceived || needsAmount) { txsByHash.delete(parsed.hash);
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;
}
// 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. // Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || ""); const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed); txsByHash.set(ttKey, parsed);
} }

View File

@@ -359,12 +359,9 @@ 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;
// Always update output: in multi-step swaps (V3 → V4), if (!minOutput) minOutput = s.amountOutMin;
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
} }
} }
@@ -372,9 +369,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;
outputToken = s.tokenOut; if (!minOutput) minOutput = s.amountOutMin;
minOutput = s.amountOutMin;
} }
} }
@@ -391,11 +388,12 @@ 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;
// Always update output: last swap step wins if (!minOutput && v4.amountOutMin)
if (v4.tokenOut) outputToken = v4.tokenOut; minOutput = v4.amountOutMin;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
} }
} }
@@ -447,18 +445,12 @@ function decode(data, toAddress) {
const maxUint160 = BigInt( const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff", "0xffffffffffffffffffffffffffffffffffffffff",
); );
const isUnlimited = inputAmount >= maxUint160; const amountStr =
const amountRaw = isUnlimited inputAmount >= maxUint160
? "Unlimited" ? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals); : formatAmount(inputAmount, inInfo.decimals) +
const amountStr = isUnlimited (inSymbol ? " " + inSymbol : "");
? "Unlimited" details.push({ label: "Amount", value: amountStr });
: amountRaw + (inSymbol ? " " + inSymbol : "");
details.push({
label: "Amount",
value: amountStr,
rawValue: amountRaw,
});
} }
if (outSymbol) { if (outSymbol) {