Compare commits

..

4 Commits

Author SHA1 Message Date
user
005b97cdab restyle add-wallet tabs: 'From' prefix, underline tab style
All checks were successful
check / check (push) Successful in 1m1s
- Tab labels: 'From Phrase', 'From Key', 'From xprv'
- Visual: bottom-border underline on active tab (not filled buttons)
- Inactive tabs: muted text with hover highlight
- Container: bottom border connects tabs to content area
2026-02-28 12:44:48 -08:00
user
0d110914a7 refactor: unify add-wallet, import-key, and import-xprv into single view
Merge all three wallet import methods (recovery phrase, private key,
extended key/xprv) into one tabbed add-wallet view with a mode selector.
This fixes the blank import-xprv render (it was missing from the VIEWS
array) and the broken back-button navigation from the separate import
views.

- Add tab selector: Recovery Phrase | Private Key | Extended Key (xprv)
- Share password fields across all modes
- Remove separate import-key and import-xprv views and modules
- Add duplicate wallet detection for private key imports
- All tabs follow affordance policy (visible border + hover state)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:44:48 -08:00
user
612df5cfca fix: derive xprv addresses from correct BIP44 path (m/44'/60'/0'/0)
hdWalletFromXprv() and getSignerForAddress() for xprv type were deriving
addresses directly from the root key (m/N) instead of the standard BIP44
Ethereum path (m/44'/60'/0'/0/N). This caused imported xprv wallets to
generate completely wrong addresses.

Navigate to the BIP44 Ethereum derivation path before deriving child
addresses, matching the behavior of mnemonic-based wallet imports.
2026-02-28 12:44:48 -08:00
user
eba962b271 feat: add xprv wallet import support
Add the ability to import an existing HD wallet using an extended
private key (xprv) instead of a mnemonic phrase.

- New 'xprv' wallet type with full HD derivation and address scanning
- New importXprv view with password encryption
- Updated getSignerForAddress to handle xprv wallet type
- Added xprv link to the add-wallet view
- Allow adding derived addresses for xprv wallets

Closes #20
2026-02-28 12:44:48 -08:00
13 changed files with 468 additions and 417 deletions

View File

@@ -56,9 +56,37 @@
&lt; Back &lt; Back
</button> </button>
<h2 class="font-bold mb-2">Add Wallet</h2> <h2 class="font-bold mb-2">Add Wallet</h2>
<!-- Mode selector tabs -->
<div
class="flex border-b border-border mb-3"
id="add-wallet-tabs"
>
<button
id="tab-mnemonic"
class="px-2 py-1 cursor-pointer text-xs border-b-2 border-fg font-bold"
>
From Phrase
</button>
<button
id="tab-privkey"
class="px-2 py-1 cursor-pointer text-xs border-b-2 border-transparent text-muted hover:text-fg"
>
From Key
</button>
<button
id="tab-xprv"
class="px-2 py-1 cursor-pointer text-xs border-b-2 border-transparent text-muted hover:text-fg"
>
From xprv
</button>
</div>
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<p class="mb-2"> <p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the Enter your 12 or 24 word recovery phrase below, or click
button to roll the die for a new one. the button to roll the die for a new one.
</p> </p>
<div class="mb-1 flex justify-end"> <div class="mb-1 flex justify-end">
<button <button
@@ -81,12 +109,51 @@
id="add-wallet-phrase-warning" id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden" class="text-xs mb-2 border border-border border-dashed p-2 hidden"
> >
Write these words down and keep them safe. Anyone with them Write these words down and keep them safe. Anyone with
can take your funds; if you lose them, your wallet is gone. them can take your funds; if you lose them, your wallet
is gone.
</div> </div>
</div>
<!-- Private key form section -->
<div id="add-wallet-section-privkey" class="hidden">
<p class="mb-2">
Paste your private key below. This wallet will have a
single address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
</div>
<!-- Extended key (xprv) form section -->
<div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses.
</p>
<div class="mb-2">
<input
type="password"
id="import-xprv-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="xprv..."
/>
</div>
</div>
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1"> <p
class="text-xs text-muted mb-1"
id="add-wallet-password-hint"
>
This password encrypts your recovery phrase on this This password encrypts your recovery phrase on this
device. You will need it to send funds. device. You will need it to send funds.
</p> </p>
@@ -107,64 +174,6 @@
<button <button
id="btn-add-wallet-confirm" id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
id="btn-add-wallet-import-key"
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
>
Import private key
</button>
</div>
</div>
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
<div class="mb-2" id="import-key-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your private key on this device.
You will need it to send funds.
</p>
<input
type="password"
id="import-key-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-key-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-key-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Import Import
</button> </button>
@@ -581,23 +590,11 @@
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>
@@ -676,6 +673,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
@@ -1115,10 +1148,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"
@@ -1181,10 +1211,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,7 +10,6 @@ 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");
@@ -54,7 +53,6 @@ const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
showAddWalletView: () => addWallet.show(), showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(), showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(), showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(), showAddTokenView: () => addToken.show(),
@@ -74,7 +72,6 @@ const RESTORABLE_VIEWS = new Set([
"receive", "receive",
"settings", "settings",
"settings-addtoken", "settings-addtoken",
"confirm-tx",
"transaction", "transaction",
"success-tx", "success-tx",
"error-tx", "error-tx",
@@ -128,13 +125,6 @@ function restoreView() {
case "settings-addtoken": case "settings-addtoken":
settingsAddToken.show(); settingsAddToken.show();
break; break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView();
}
break;
case "transaction": case "transaction":
if (state.viewData && state.viewData.tx) { if (state.viewData && state.viewData.tx) {
transactionDetail.render(); transactionDetail.render();
@@ -217,7 +207,6 @@ async function init() {
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx); home.init(ctx);
addressDetail.init(ctx); addressDetail.init(ctx);
addressToken.init(ctx); addressToken.init(ctx);

View File

@@ -3,31 +3,72 @@ 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");
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);
tab.classList.toggle("border-fg", m === mode);
tab.classList.toggle("font-bold", m === mode);
tab.classList.toggle("border-transparent", m !== mode);
tab.classList.toggle("text-muted", m !== mode);
}
$("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 init(ctx) { function validatePassword() {
$("btn-generate-phrase").addEventListener("click", () => { const pw = $("add-wallet-password").value;
$("wallet-mnemonic").value = generateMnemonic(); const pw2 = $("add-wallet-password-confirm").value;
$("add-wallet-phrase-warning").classList.remove("hidden"); if (!pw) {
}); showFlash("Please choose a password.");
return null;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return null;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return null;
}
return pw;
}
$("btn-add-wallet-confirm").addEventListener("click", async () => { async function importMnemonic(ctx) {
const mnemonic = $("wallet-mnemonic").value.trim(); const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) { if (!mnemonic) {
showFlash( showFlash("Enter a recovery phrase or press the die to generate one.");
"Enter a recovery phrase or press the die to generate one.",
);
return; return;
} }
const words = mnemonic.split(/\s+/); const words = mnemonic.split(/\s+/);
@@ -43,33 +84,18 @@ function init(ctx) {
showFlash("Invalid recovery phrase. Check for typos."); showFlash("Invalid recovery phrase. Check for typos.");
return; return;
} }
const pw = $("add-wallet-password").value; const pw = validatePassword();
const pw2 = $("add-wallet-password-confirm").value; if (!pw) return;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find( const duplicate = state.wallets.find(
(w) => (w) =>
w.type === "hd" && w.type === "hd" &&
w.addresses[0] && w.addresses[0] &&
w.addresses[0].address.toLowerCase() === w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
firstAddress.toLowerCase(),
); );
if (duplicate) { if (duplicate) {
showFlash( showFlash(
"This recovery phrase is already added (" + "This recovery phrase is already added (" + duplicate.name + ").",
duplicate.name +
").",
); );
return; return;
} }
@@ -109,8 +135,143 @@ function init(ctx) {
} }
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
}
async function importPrivateKey(ctx) {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showFlash("Invalid private key.");
return;
}
const pw = validatePassword();
if (!pw) return;
const duplicate = 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);
}
});
// Back button
$("btn-add-wallet-back").addEventListener("click", () => { $("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");
@@ -119,11 +280,6 @@ function init(ctx) {
showView("main"); showView("main");
} }
}); });
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -325,9 +325,6 @@ function init(_ctx) {
$("export-privkey-flash").classList.remove("hidden"); $("export-privkey-flash").classList.remove("hidden");
return; return;
} }
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
try { try {
const secret = await decryptWithPassword( const secret = await decryptWithPassword(
@@ -347,9 +344,6 @@ function init(_ctx) {
} catch { } catch {
$("export-privkey-flash").textContent = "Wrong password."; $("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden"); $("export-privkey-flash").classList.remove("hidden");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
} }
}); });

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,
@@ -39,13 +39,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}`;
} }
@@ -86,42 +79,6 @@ function valueWithUsd(text, usdAmount) {
return text; return text;
} }
function renderWarnings(warnings) {
const warningsEl = $("confirm-warnings");
if (warnings.length > 0) {
warningsEl.innerHTML = warnings
.map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold" style="color:#c00">WARNING: ${w}</div>`,
)
.join("");
warningsEl.classList.remove("hidden");
} else {
warningsEl.classList.add("hidden");
}
}
async function checkAddressHistory(address, existingWarnings) {
try {
const provider = getProvider(state.rpcUrl);
const [balance, txCount] = await Promise.all([
provider.getBalance(address),
provider.getTransactionCount(address),
]);
if (balance === 0n && txCount === 0) {
const warnings = existingWarnings.slice();
warnings.push(
"This address has ZERO transaction history. " +
"It has never sent or received funds. " +
"Double-check that the address is correct before sending.",
);
renderWarnings(warnings);
}
} catch (e) {
log.errorf("address history check failed:", e.message);
}
}
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
@@ -212,10 +169,18 @@ function show(txInfo) {
warnings.push("You are sending to your own address."); warnings.push("You are sending to your own address.");
} }
renderWarnings(warnings); const warningsEl = $("confirm-warnings");
if (warnings.length > 0) {
// Async check: warn if destination address has zero transaction history warningsEl.innerHTML = warnings
checkAddressHistory(txInfo.to, warnings); .map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
)
.join("");
warningsEl.classList.remove("hidden");
} else {
warningsEl.classList.add("hidden");
}
// Check for errors // Check for errors
const errors = []; const errors = [];
@@ -261,14 +226,9 @@ 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");
estimateGas(txInfo); estimateGas(txInfo);
@@ -314,20 +274,39 @@ async function estimateGas(txInfo) {
} }
} }
function showPasswordModal() {
$("modal-password").value = "";
hideError("modal-password-error");
$("password-modal").classList.remove("hidden");
}
function hidePasswordModal() {
$("password-modal").classList.add("hidden");
}
function init(ctx) { function init(ctx) {
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", () => {
const password = $("confirm-tx-password").value; showPasswordModal();
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
$("btn-modal-cancel").addEventListener("click", () => {
hidePasswordModal();
});
$("btn-modal-confirm").addEventListener("click", async () => {
const password = $("modal-password").value;
if (!password) { if (!password) {
showError( showError("modal-password-error", "Please enter your password.");
"confirm-tx-password-error",
"Please enter your password.",
);
return; return;
} }
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
let decryptedSecret; let decryptedSecret;
hideError("confirm-tx-password-error"); hideError("modal-password-error");
try { try {
decryptedSecret = await decryptWithPassword( decryptedSecret = await decryptWithPassword(
@@ -335,12 +314,11 @@ function init(ctx) {
password, password,
); );
} catch (e) { } catch (e) {
showError("confirm-tx-password-error", "Wrong password."); showError("modal-password-error", "Wrong password.");
return; return;
} }
$("btn-confirm-send").disabled = true; hidePasswordModal();
$("btn-confirm-send").classList.add("text-muted");
let tx; let tx;
try { try {
@@ -377,15 +355,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,7 +13,6 @@ const { state, saveState } = require("../../shared/state");
const VIEWS = [ const VIEWS = [
"welcome", "welcome",
"add-wallet", "add-wallet",
"import-key",
"main", "main",
"address", "address",
"address-token", "address-token",

View File

@@ -239,7 +239,7 @@ function render(ctx) {
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd") { if (wallet.type === "hd" || wallet.type === "xprv") {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`; html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
} }
html += `</div>`; html += `</div>`;

View File

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

View File

@@ -43,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) {
@@ -140,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>`;
@@ -165,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;
const hasDecoded = d.decoded && d.decoded.details;
// 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-summary").textContent = d.amount + " " + d.symbol;
$("success-tx-to").innerHTML = toAddressHtml(d.to); $("success-tx-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").textContent = String(d.blockNumber); $("success-tx-block").textContent = String(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

@@ -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) {

View File

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