Compare commits

..

1 Commits

Author SHA1 Message Date
user
1df770d3b6 fix: prevent double symbol display on swap tx broadcast/status views
All checks were successful
check / check (push) Successful in 22s
For Uniswap swaps, the decoded Amount value includes the token symbol
(e.g. '2.0000 USDT'). The tx status view then appended 'ETH' because
pendingTxDetails.token remained 'ETH' (router address is not a token).

Fix:
- Add rawValue (numeric-only) to uniswap decoder's Amount detail
- Extract Token In address from decoded details in approval.js to set
  the correct token/tokenSymbol on pendingTxDetails for swaps
- The existing d.rawValue || d.value pattern now picks up the clean
  numeric value, and symbol comes from tokenSymbol
2026-02-28 11:30:44 -08:00
19 changed files with 311 additions and 836 deletions

View File

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

View File

@@ -10,6 +10,7 @@ const { $, showView } = require("./views/helpers");
const home = require("./views/home");
const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
@@ -53,6 +54,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(),
@@ -207,6 +209,7 @@ async function init() {
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,9 +167,11 @@ function showTxApproval(details) {
tokenSymbol: token ? token.symbol : null,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
// If this is an ERC-20 call or a swap, extract the real recipient, amount, and token info
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenSymbol = null;
let decodedTokenAddress = null;
for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
@@ -177,22 +179,22 @@ function showTxApproval(details) {
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
}
if (d.label === "Token In" && !decodedTokenSymbol) {
// Extract token symbol and address from decoded details
decodedTokenSymbol = d.value;
if (d.address) decodedTokenAddress = d.address;
}
}
if (token) {
pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddress) {
// For swaps through routers: use the input token info
pendingTxDetails.token = decodedTokenAddress;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
}
}
// Carry decoded calldata info through to success/error views
if (decoded) {
pendingTxDetails.decoded = {
name: decoded.name,
description: decoded.description,
details: decoded.details,
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);

View File

@@ -14,7 +14,6 @@ const {
showError,
hideError,
showView,
showFlash,
addressTitle,
addressDotHtml,
escapeHtml,
@@ -96,22 +95,11 @@ function show(txInfo) {
// Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section");
if (isErc20) {
const dot = addressDotHtml(txInfo.token);
const link = etherscanTokenLink(txInfo.token);
$("confirm-token-contract").innerHTML =
`<div class="flex items-center">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
escapeHtml(txInfo.token) +
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
tokenSection.classList.remove("hidden");
// Attach click-to-copy on the contract address
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
};
}
} else {
tokenSection.classList.add("hidden");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -143,10 +143,11 @@ function render() {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
if (headingEl) headingEl.textContent = tx.directionLabel;
} else {
if (typeSection) typeSection.classList.add("hidden");
if (headingEl) headingEl.textContent = "Transaction";
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");

View File

@@ -8,7 +8,6 @@ const {
addressTitle,
escapeHtml,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -122,51 +121,11 @@ function showSuccess(txInfo, txHash, blockNumber) {
to: txInfo.to,
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = "";
if (decoded.name) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
}
if (decoded.description) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
}
for (const d of decoded.details) {
html += `<div class="mb-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address);
} else {
html += toAddressHtml(d.address);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
return html;
}
function renderSuccess() {
const d = state.viewData;
if (!d || !d.hash) return;
@@ -174,16 +133,6 @@ function renderSuccess() {
$("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && d.decoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
}
attachCopyHandlers("view-success-tx");
showView("success-tx");
}

View File

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

View File

@@ -153,11 +153,9 @@ 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. Replace the normal tx with the token transfer,
// but preserve contract call metadata (direction, label, method) so
// swaps and other contract interactions display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
@@ -166,13 +164,8 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
txsByHash.set(parsed.hash, parsed);
}
const txs = [...txsByHash.values()];

View File

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

View File

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