Compare commits

..

1 Commits

Author SHA1 Message Date
user
2f11c43dd8 fix: validate destination address on send view
All checks were successful
check / check (push) Successful in 21s
- Validate Ethereum addresses (0x + 40 hex chars) and ENS names
- EIP-55 checksum validation for mixed-case addresses
- Block sending to zero address (0x0000...0000)
- Warn when sending to own address (allow but show warning)
- Inline error messages with reserved space (no layout shift)
- Disable Review button while address is invalid

Closes #67
2026-02-28 11:44:40 -08:00
19 changed files with 363 additions and 901 deletions

View File

@@ -56,104 +56,37 @@
< Back < Back
</button> </button>
<h2 class="font-bold mb-2">Add Wallet</h2> <h2 class="font-bold mb-2">Add Wallet</h2>
<p class="mb-2">
<!-- Mode selector tabs --> 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 <div
class="flex border-b border-border mb-3" id="add-wallet-phrase-warning"
id="add-wallet-tabs" class="text-xs mb-2 border border-border border-dashed p-2 hidden"
> >
<button Write these words down and keep them safe. Anyone with them
id="tab-mnemonic" can take your funds; if you lose them, your wallet is gone.
class="px-3 py-1.5 cursor-pointer text-xs font-bold border border-border border-b-bg bg-bg -mb-px"
>
From Phrase
</button>
<button
id="tab-privkey"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From Key
</button>
<button
id="tab-xprv"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From xprv
</button>
</div> </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>
<!-- Private key form section -->
<div id="add-wallet-section-privkey" class="hidden">
<p class="mb-2">
Paste your private key below. This wallet will have a
single address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
</div>
<!-- Extended key (xprv) form section -->
<div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses.
</p>
<div class="mb-2">
<input
type="password"
id="import-xprv-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="xprv..."
/>
</div>
</div>
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <label class="block mb-1">Choose a password</label>
<p <p class="text-xs text-muted mb-1">
class="text-xs text-muted mb-1"
id="add-wallet-password-hint"
>
This password encrypts your recovery phrase on this This password encrypts your recovery phrase on this
device. You will need it to send funds. device. You will need it to send funds.
</p> </p>
@@ -174,6 +107,64 @@
<button <button
id="btn-add-wallet-confirm" id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
id="btn-add-wallet-import-key"
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
>
Import private key
</button>
</div>
</div>
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
<div class="mb-2" id="import-key-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your private key on this device.
You will need it to send funds.
</p>
<input
type="password"
id="import-key-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-key-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-key-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Import Import
</button> </button>
@@ -314,26 +305,6 @@
> >
+ Token + Token
</button> </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> </div>
<!-- transactions --> <!-- transactions -->
@@ -347,60 +318,6 @@
</div> </div>
</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 ============ --> <!-- ============ ADDRESS-TOKEN DETAIL VIEW ============ -->
<div id="view-address-token" class="view hidden"> <div id="view-address-token" class="view hidden">
<button <button
@@ -586,40 +503,15 @@
<div id="confirm-fee-amount" class="text-xs"></div> <div id="confirm-fee-amount" class="text-xs"></div>
</div> </div>
<div id="confirm-warnings" class="mb-2 hidden"></div> <div id="confirm-warnings" class="mb-2 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 hidden" class="mb-2 border border-border border-dashed p-2 hidden"
></div> ></div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
type="password"
id="confirm-tx-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
></div>
<button <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>
@@ -698,6 +590,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
@@ -714,10 +642,9 @@
<div class="flex justify-center mb-3"> <div class="flex justify-center mb-3">
<canvas id="receive-qr"></canvas> <canvas id="receive-qr"></canvas>
</div> </div>
<div <div class="border border-border p-2 break-all mb-3 text-xs">
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer" <span id="receive-dot"></span>
> <span id="receive-address" class="select-all"></span>
<span id="receive-address-block" class="select-all"></span>
<span id="receive-etherscan-link"></span> <span id="receive-etherscan-link"></span>
</div> </div>
<button <button
@@ -1137,10 +1064,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] hidden"
></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
id="btn-approve-tx" id="btn-approve-tx"
@@ -1203,10 +1127,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] hidden"
></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
id="btn-approve-sign" id="btn-approve-sign"

View File

@@ -10,6 +10,7 @@ const { $, showView } = require("./views/helpers");
const home = require("./views/home"); const home = require("./views/home");
const welcome = require("./views/welcome"); const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet"); const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail"); const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken"); const addressToken = require("./views/addressToken");
const send = require("./views/send"); const send = require("./views/send");
@@ -53,6 +54,7 @@ const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
showAddWalletView: () => addWallet.show(), showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(), showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(), showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(), showAddTokenView: () => addToken.show(),
@@ -72,7 +74,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",
@@ -126,13 +127,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();
@@ -215,6 +209,7 @@ async function init() {
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx); home.init(ctx);
addressDetail.init(ctx); addressDetail.init(ctx);
addressToken.init(ctx); addressToken.init(ctx);

View File

@@ -11,7 +11,6 @@
--color-border-light: #cccccc; --color-border-light: #cccccc;
--color-hover: #eeeeee; --color-hover: #eeeeee;
--color-well: #f5f5f5; --color-well: #f5f5f5;
--color-danger-well: #fef2f2;
--color-section: #dddddd; --color-section: #dddddd;
} }
@@ -19,16 +18,3 @@ 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 300ms ease-out,
color 300ms ease-out;
}

View File

@@ -3,299 +3,114 @@ const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
isValidMnemonic, isValidMnemonic,
addressFromPrivateKey,
hdWalletFromXprv,
isValidXprv,
} = require("../../shared/wallet"); } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances"); const { scanForAddresses } = require("../../shared/balances");
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
* Returns the wallet object if found, or undefined.
*/
function findWalletByAddress(addr) {
const lower = addr.toLowerCase();
return state.wallets.find((w) =>
w.addresses.some((a) => a.address.toLowerCase() === lower),
);
}
/**
* Check if an xpub already exists in any HD-type wallet (hd or xprv).
* Returns the wallet object if found, or undefined.
*/
function findWalletByXpub(xpub) {
return state.wallets.find((w) => w.xpub && w.xpub === xpub);
}
let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"];
const PASSWORD_HINTS = {
mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds.",
privkey:
"This password encrypts your private key on this device. You will need it to send funds.",
xprv: "This password encrypts your key on this device. You will need it to send funds.",
};
function switchMode(mode) {
currentMode = mode;
for (const m of MODES) {
$("add-wallet-section-" + m).classList.toggle("hidden", m !== mode);
const tab = $("tab-" + m);
const isActive = m === mode;
// Active: bold, solid border on top/sides, no bottom border (connects to content)
tab.classList.toggle("font-bold", isActive);
tab.classList.toggle("border-solid", isActive);
tab.classList.toggle("border-border", isActive);
tab.classList.toggle("border-b-bg", isActive);
tab.classList.toggle("bg-bg", isActive);
// Inactive: muted text, dashed border on top/sides, transparent bottom, hover invert
tab.classList.toggle("text-muted", !isActive);
tab.classList.toggle("border-dashed", !isActive);
tab.classList.toggle("border-border-light", !isActive);
tab.classList.toggle("border-b-transparent", !isActive);
tab.classList.toggle("hover:bg-fg", !isActive);
tab.classList.toggle("hover:text-bg", !isActive);
}
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
}
function show() { function show() {
$("wallet-mnemonic").value = ""; $("wallet-mnemonic").value = "";
$("import-private-key").value = "";
$("import-xprv-key").value = "";
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden"); $("add-wallet-phrase-warning").classList.add("hidden");
switchMode("mnemonic");
showView("add-wallet"); 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 xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash(
"This recovery phrase is already added (" + xpubDup.name + ").",
);
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
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 = findWalletByAddress(addr);
if (duplicate) {
showFlash(
"This address already exists in wallet (" + duplicate.name + ").",
);
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
}
async function importXprvKey(ctx) {
const xprv = $("import-xprv-key").value.trim();
if (!xprv) {
showFlash("Please enter your extended private key.");
return;
}
if (!isValidXprv(xprv)) {
showFlash("Invalid extended private key.");
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch (e) {
showFlash("Invalid extended private key.");
return;
}
const { xpub, firstAddress } = result;
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash("This key is already added (" + xpubDup.name + ").");
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const pw = validatePassword();
if (!pw) return;
const encrypted = await encryptWithPassword(xprv, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "xprv",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
}
function init(ctx) { 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", () => { $("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic(); $("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden"); $("add-wallet-phrase-warning").classList.remove("hidden");
}); });
// Import / confirm
$("btn-add-wallet-confirm").addEventListener("click", async () => { $("btn-add-wallet-confirm").addEventListener("click", async () => {
if (currentMode === "mnemonic") { const mnemonic = $("wallet-mnemonic").value.trim();
await importMnemonic(ctx); if (!mnemonic) {
} else if (currentMode === "privkey") { showFlash(
await importPrivateKey(ctx); "Enter a recovery phrase or press the die to generate one.",
} else if (currentMode === "xprv") { );
await importXprvKey(ctx); 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", () => { $("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");
@@ -304,6 +119,11 @@ function init(ctx) {
showView("main"); showView("main");
} }
}); });
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -2,7 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
@@ -16,15 +15,9 @@ 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 { getSignerForAddress } = require("../../shared/wallet");
let ctx; let ctx;
@@ -95,10 +88,6 @@ 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");
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const absOff = Math.abs(off);
const tz = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return ( return (
d.getFullYear() + d.getFullYear() +
"-" + "-" +
@@ -110,8 +99,7 @@ function isoDate(timestamp) {
":" + ":" +
pad(d.getMinutes()) + pad(d.getMinutes()) +
":" + ":" +
pad(d.getSeconds()) + pad(d.getSeconds())
tz
); );
} }
@@ -247,7 +235,6 @@ function init(_ctx) {
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("address-full"));
} }
}); });
@@ -270,7 +257,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");
}); });
@@ -279,110 +265,6 @@ function init(_ctx) {
}); });
$("btn-add-token").addEventListener("click", ctx.showAddTokenView); $("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 btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
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");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
}
});
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-address"));
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
show();
});
} }
module.exports = { init, show }; module.exports = { init, show };

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,10 +43,6 @@ 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");
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const absOff = Math.abs(off);
const tz = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return ( return (
d.getFullYear() + d.getFullYear() +
"-" + "-" +
@@ -63,8 +54,7 @@ function isoDate(timestamp) {
":" + ":" +
pad(d.getMinutes()) + pad(d.getMinutes()) +
":" + ":" +
pad(d.getSeconds()) + pad(d.getSeconds())
tz
); );
} }
@@ -323,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"));
} }
}); });
@@ -332,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);
} }
}); });
@@ -381,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 = "";
$("approve-tx-error").classList.add("hidden");
showView("approve-tx"); showView("approve-tx");
} }
@@ -359,7 +342,7 @@ function showSignApproval(details) {
} }
$("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");
} }
@@ -235,21 +214,12 @@ function show(txInfo) {
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").classList.remove("hidden"); $("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) {
@@ -292,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(
@@ -333,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 {
@@ -375,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

@@ -40,10 +40,6 @@ function init(_ctx) {
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];
@@ -53,8 +49,6 @@ function init(_ctx) {
} catch (_e) { } catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password."; $("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden"); $("delete-wallet-flash").classList.remove("hidden");
btn.disabled = false;
btn.classList.remove("text-muted");
return; return;
} }

View File

@@ -13,6 +13,7 @@ const { state, saveState } = require("../../shared/state");
const VIEWS = [ const VIEWS = [
"welcome", "welcome",
"add-wallet", "add-wallet",
"import-key",
"main", "main",
"address", "address",
"address-token", "address-token",
@@ -30,7 +31,6 @@ const VIEWS = [
"approve-site", "approve-site",
"approve-tx", "approve-tx",
"approve-sign", "approve-sign",
"export-privkey",
]; ];
function $(id) { function $(id) {
@@ -226,10 +226,6 @@ 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");
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const absOff = Math.abs(off);
const tz = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return ( return (
d.getFullYear() + d.getFullYear() +
"-" + "-" +
@@ -241,8 +237,7 @@ function isoDate(timestamp) {
":" + ":" +
pad(d.getMinutes()) + pad(d.getMinutes()) +
":" + ":" +
pad(d.getSeconds()) + pad(d.getSeconds())
tz
); );
} }
@@ -263,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");
}, 350);
}, 100);
}
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 = "";
@@ -241,7 +235,7 @@ function render(ctx) {
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd" || 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 += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
} }
html += `</div>`; html += `</div>`;
@@ -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

@@ -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,11 +1,4 @@
const { const { $, showView, showFlash, addressDotHtml } = require("./helpers");
$,
showView,
showFlash,
flashCopyFeedback,
formatAddressHtml,
addressTitle,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
@@ -19,12 +12,8 @@ const EXT_ICON =
function show() { function show() {
const addr = currentAddress(); const addr = currentAddress();
const address = addr ? addr.address : ""; const address = addr ? addr.address : "";
const title = address ? addressTitle(address, state.wallets) : null; $("receive-dot").innerHTML = address ? addressDotHtml(address) : "";
const ensName = addr ? addr.ensName || null : null; $("receive-address").textContent = address;
$("receive-address-block").innerHTML = address
? formatAddressHtml(address, ensName, null, title)
: "";
$("receive-address-block").dataset.full = address;
const link = address ? `https://etherscan.io/address/${address}` : ""; const link = address ? `https://etherscan.io/address/${address}` : "";
$("receive-etherscan-link").innerHTML = link $("receive-etherscan-link").innerHTML = link
? `<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>`
@@ -61,21 +50,11 @@ function show() {
} }
function init(ctx) { function init(ctx) {
$("receive-address-block").addEventListener("click", (e) => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}
});
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full; const addr = $("receive-address").textContent;
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback($("receive-address-block"));
} }
}); });

View File

@@ -276,19 +276,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

@@ -5,7 +5,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
@@ -159,9 +158,8 @@ function render() {
loadCalldata(tx.hash, tx.to); loadCalldata(tx.hash, tx.to);
} }
const isoStr = isoDate(tx.timestamp); $("tx-detail-time").textContent =
$("tx-detail-time").innerHTML = isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
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");
@@ -172,7 +170,6 @@ function render() {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(el);
}; };
}); });
} }
@@ -250,7 +247,6 @@ async function loadCalldata(txHash, toAddress) {
el.onclick = () => { el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy); navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!"); showFlash("Copied!");
flashCopyFeedback(el);
}; };
}); });
} }

View File

@@ -4,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

@@ -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 // When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real // is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. A single transaction (e.g. a swap) can produce // amount and symbol. Replace the normal tx with the token transfer,
// multiple token transfers (one per token involved), so we key token // but preserve contract call metadata (direction, label, method) so
// transfers by hash + contract address to keep all of them. We also // swaps and other contract interactions display correctly.
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
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);
@@ -166,13 +164,8 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
parsed.directionLabel = existing.directionLabel; parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true; parsed.isContractCall = true;
parsed.method = existing.method; 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. txsByHash.set(parsed.hash, parsed);
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
} }
const txs = [...txsByHash.values()]; const txs = [...txsByHash.values()];

View File

@@ -445,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) {
@@ -487,10 +481,7 @@ function decode(data, toAddress) {
const deadlineDate = new Date(Number(deadline) * 1000); const deadlineDate = new Date(Number(deadline) * 1000);
details.push({ details.push({
label: "Deadline", label: "Deadline",
value: deadlineDate value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
.toISOString()
.replace("T", " ")
.replace(".000Z", "Z"),
}); });
return { return {

View File

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