diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js
index b630c825..8d1becf3 100644
--- a/bundled-addons/aegis/index.js
+++ b/bundled-addons/aegis/index.js
@@ -975,6 +975,32 @@ function buildWcApproval(payload) {
}
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?");
const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }];
+ // What the wallet is putting IN. The outputs alone never showed that a
+ // transaction spends the user's tokens — and with the token prefix now
+ // signed correctly (wc-sign.js) such a transaction is valid, so the
+ // tokens leaving must be on the overlay.
+ const hexOf = (v) => (typeof v === "string" ? v.toLowerCase() : Buffer.from(v || []).toString("hex"));
+ const tokenText = (t) => {
+ if (!t) return "";
+ const cat = hexOf(t.category);
+ let amt = "0";
+ try { amt = BigInt(t.amount ?? 0).toString(); } catch {}
+ const nft = t.nft ? ` + NFT (${String(t.nft.capability || "none")})` : "";
+ return ` + token ${cat.slice(0, 8)}…${cat.slice(-4)}: ${amt} units${nft}`;
+ };
+ try {
+ const srcs = Array.isArray(tx.sourceOutputs) ? tx.sourceOutputs : [];
+ if (srcs.length) {
+ let inTotal = 0n;
+ const tokenLines = [];
+ srcs.forEach((o, i) => {
+ try { inTotal += BigInt(o.valueSatoshis ?? 0); } catch {}
+ if (o.token) tokenLines.push(`input #${i + 1}${tokenText(o.token)}`);
+ });
+ rows.push({ label: "Spending", value: `${fmtBch(Number(inTotal))} BCH from ${srcs.length} input${srcs.length === 1 ? "" : "s"}` });
+ if (tokenLines.length) rows.push({ label: "Tokens spent", value: tokenLines.slice(0, 8).join("\n") + (tokenLines.length > 8 ? `\n… and ${tokenLines.length - 8} more` : ""), mono: true, strong: true });
+ }
+ } catch {}
try {
const outs = inner?.outputs || [];
let total = 0n;
@@ -986,7 +1012,7 @@ function buildWcApproval(payload) {
else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44)));
const v = BigInt(o.valueSatoshis ?? 0);
total += v;
- const token = o.token ? " + CashTokens" : "";
+ const token = tokenText(o.token);
rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true });
});
if (outs.length > 8) rows.push({ label: "Outputs", value: `… and ${outs.length - 8} more` });
@@ -1160,6 +1186,86 @@ function requireWallet(id) {
return rt;
}
function requireSelected() { return requireWallet(selectedWalletId()); }
+
+// ---- PIN: sealed blob + host-verified transaction clearance ---------------
+// The PIN blob is the vault master password wrapped under a 6-digit PIN. In
+// plain add-on storage that made the master password exactly as strong as a
+// million PBKDF2 guesses to anyone holding a copy of the profile (a backup,
+// another machine, a disk image) — the panel's lockout counter never sees an
+// offline guess. It is therefore sealed with the OS keystore (DPAPI /
+// Keychain / libsecret) before it touches disk, the same as Theseus's own
+// vault PIN: a copied file is useless without this OS account. A plain blob
+// from an older build is sealed the first time it is read.
+const PIN_BLOB_KEY = "aegis/pin/v1";
+function sealPinBlob(api, blob) {
+ const ss = safeStorageOr(api);
+ try {
+ if (ss && ss.isEncryptionAvailable()) {
+ return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") };
+ }
+ } catch { /* fall through: unsealed is still better than no PIN at all */ }
+ return blob;
+}
+function openPinBlob(api) {
+ const b = api.storage.get(PIN_BLOB_KEY, null);
+ if (!b || typeof b !== "object") return null;
+ if (b.sealed) {
+ const ss = safeStorageOr(api);
+ if (!ss) return null;
+ try {
+ const plain = JSON.parse(ss.decryptString(Buffer.from(String(b.sealed), "base64")));
+ return (plain && typeof plain === "object") ? plain : null;
+ } catch { return null; } // sealed under another OS account: the PIN is simply gone
+ }
+ const plain = { salt: b.salt, iv: b.iv, ct: b.ct, iters: b.iters };
+ const sealed = sealPinBlob(api, plain);
+ if (sealed.sealed) api.storage.set(PIN_BLOB_KEY, sealed);
+ return plain;
+}
+
+// "Ask for PIN on every transaction" used to be decided by the host and
+// enforced by nobody: `send` never checked it, and a dapp-initiated
+// transaction had no PIN step at all. A clearance is now a short-lived,
+// single-use fact recorded only after the host itself has verified the
+// master password the PIN unwraps (pinGateSatisfied). Panel sends consume
+// one; dapp transactions ask the open panel for one and wait.
+const TX_CLEARANCE_MS = 90_000;
+const DAPP_PIN_WAIT_MS = 120_000;
+let txClearanceUntil = 0;
+let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN
+function txPinOwed() {
+ try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); }
+ catch { return true; } // the safe direction for a lock is closed
+}
+function takeTxClearance() {
+ if (Date.now() < txClearanceUntil) { txClearanceUntil = 0; return true; }
+ return false;
+}
+function requirePanelTxPin() {
+ if (txPinOwed() && !takeTxClearance()) throw new Error("PIN required — confirm your PIN to continue");
+}
+async function requireDappTxPin(origin, what) {
+ if (!txPinOwed() || takeTxClearance()) return;
+ pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() };
+ try {
+ try { ctx.api.emit("pinRequest", pendingPinRequest); } catch {}
+ const deadline = Date.now() + DAPP_PIN_WAIT_MS;
+ while (Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 250));
+ if (!ctx) break;
+ if (takeTxClearance()) return;
+ }
+ } finally { pendingPinRequest = null; }
+ throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again.");
+}
+
+// Signed text shown on an approval overlay. Long messages are cut for the
+// overlay's sake, but never silently: the cut says how much is missing, so a
+// benign-looking opening cannot hide what the rest commits the user to.
+function previewText(text, max = 1500) {
+ const s = String(text);
+ return s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s;
+}
function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); }
function fromPage(m) {
if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message");
@@ -1635,6 +1741,7 @@ function registerPanelMessages(api) {
});
api.onMessage("sendToken", async (p, m) => {
fromPanel(m);
+ requirePanelTxPin();
const rt = requireSelected();
if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only");
const plan = await rt.adapter.planTokenTransfer(p || {});
@@ -1659,6 +1766,13 @@ function registerPanelMessages(api) {
// Execute a send with approval overlay.
api.onMessage("send", async (p, m) => {
fromPanel(m);
+ requirePanelTxPin();
+ // The panel names the wallet its form was built for. If the selection
+ // moved since (another surface, a late state push), refuse rather than
+ // send this amount from a different wallet.
+ if (p && p.walletId && String(p.walletId) !== selectedWalletId()) {
+ throw new Error("the selected wallet changed — review the send and try again");
+ }
const rt = requireSelected();
const plan = await Promise.resolve(rt.adapter.plan(p || {}));
const d = describePlan(plan, rt.entry.chain, rt.entry.network);
@@ -1752,26 +1866,39 @@ function registerPanelMessages(api) {
api.onMessage("consolidateIntoSelected", async (p, m) => {
fromPanel(m);
+ requirePanelTxPin();
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
+ // The destination is the wallet the PREVIEW was drawn for, not whatever
+ // is selected by the time the button is pressed: a preview painted for A
+ // followed by a switch to B used to sweep everything into B.
+ if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) {
+ throw new Error("the selected wallet changed since this preview — reopen Consolidate");
+ }
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destAddr = destRt.adapter.snapshot().address;
if (!destAddr) throw new Error("destination wallet has no receive address");
- // sourceIds are the wallets the user CHECKED in the preview. Defaults
- // to every eligible sibling if the panel omits the field (safety net,
- // shouldn't happen in normal flow).
- const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length
- ? new Set(p.sourceIds.map(String))
- : null;
+ // sourceIds are the wallets the user CHECKED. They are required: an
+ // omitted list used to mean "every sibling wallet".
+ if (!Array.isArray(p?.sourceIds) || !p.sourceIds.length) throw new Error("choose at least one wallet to consolidate");
+ const requested = new Set(p.sourceIds.map(String));
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId &&
- (!requested || requested.has(w.id)),
+ requested.has(w.id),
);
+ if (!sources.length) throw new Error("none of the chosen wallets can be consolidated into this one");
+
+ // Plan every sweep first, then put the WHOLE batch on the host overlay.
+ // This emptied wallets on the panel's say-so alone; every other spend
+ // in Aegis is confirmed on a surface the panel cannot draw.
+ const meta = chainMeta(destEntry.chain, destEntry.network);
+ const dec = meta?.decimals ?? 8;
+ const planned = [];
const results = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
@@ -1781,6 +1908,38 @@ function registerPanelMessages(api) {
}
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
+ planned.push({ src, rt, plan });
+ } catch (e) {
+ results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
+ }
+ }
+ if (planned.length) {
+ let totalNet = 0n, totalFee = 0n;
+ const rows = planned.slice(0, 12).map(({ src, plan }) => {
+ const net = BigInt(String(plan.recipients?.[0]?.value ?? 0));
+ const fee = BigInt(String(plan.fee ?? 0));
+ totalNet += net; totalFee += fee;
+ return { label: "Empty", value: `${src.label} — ${fmtValue(net.toString(), dec)} ${meta?.ticker || ""}`, mono: true };
+ });
+ for (const { plan } of planned.slice(12)) {
+ totalNet += BigInt(String(plan.recipients?.[0]?.value ?? 0));
+ totalFee += BigInt(String(plan.fee ?? 0));
+ }
+ if (planned.length > 12) rows.push({ label: "…", value: `and ${planned.length - 12} more wallets` });
+ rows.push({ label: "Into", value: `${destEntry.label} — ${destAddr}`, mono: true });
+ rows.push({ label: "Arrives", value: `${fmtValue(totalNet.toString(), dec)} ${meta?.ticker || ""}`, strong: true });
+ rows.push({ label: "Fees", value: `${fmtValue(totalFee.toString(), dec)} ${meta?.ticker || ""} across ${planned.length} transaction${planned.length === 1 ? "" : "s"}` });
+ const pick = await api.approvalModal({
+ title: `Consolidate ${planned.length} wallet${planned.length === 1 ? "" : "s"}?`,
+ origin: "Aegis wallet panel",
+ body: "Each wallet listed is emptied into the destination in its own transaction. This cannot be undone.",
+ rows,
+ actions: [{ id: "send", label: "Consolidate", primary: true }],
+ });
+ if (pick !== "send") throw new Error("cancelled");
+ }
+ for (const { src, rt, plan } of planned) {
+ try {
const r = await rt.adapter.signAndBroadcast(plan);
results.push({
walletId: src.id, label: src.label, ok: true,
@@ -1873,6 +2032,7 @@ function registerPanelMessages(api) {
api.onMessage("promoteToHd", async (p, m) => {
fromPanel(m);
+ requirePanelTxPin();
const { entry, rt } = promotableImport(p && p.walletId);
const dest = await createVaultWallet({
chain: entry.chain, network: entry.network,
@@ -2343,8 +2503,7 @@ function registerPanelMessages(api) {
// the opaque blob + a small policy object in and out of api.storage.
api.onMessage("pinBlobGet", (_p, m) => {
fromPanel(m);
- const b = api.storage.get("aegis/pin/v1", null);
- return (b && typeof b === "object") ? b : null;
+ return openPinBlob(api);
});
api.onMessage("pinBlobSet", (p, m) => {
fromPanel(m);
@@ -2353,7 +2512,7 @@ function registerPanelMessages(api) {
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") {
throw new Error("blob shape invalid");
}
- api.storage.set("aegis/pin/v1", { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters });
+ api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }));
return true;
});
api.onMessage("pinBlobClear", (_p, m) => {
@@ -2442,13 +2601,32 @@ function registerPanelMessages(api) {
const event = String(p && p.event || "panel-load");
return { ...pinNeeded(event), event, policy: pinPolicy() };
});
- // Called only after the panel has actually decrypted the PIN blob, which
- // is proof of the PIN and not merely a claim about it.
- api.onMessage("pinGateSatisfied", (_p, m) => {
+ // The panel decrypts the PIN blob and hands over what was inside it. That
+ // is the vault master password, and the host checks it against the vault
+ // itself — so a gate is cleared by proof the host verified, not by the
+ // panel saying so. The same proof opens a single-use transaction
+ // clearance (see requirePanelTxPin / requireDappTxPin).
+ api.onMessage("pinGateSatisfied", async (p, m) => {
fromPanel(m);
+ const pw = String((p && p.masterPassword) || "");
+ if (!pw) throw new Error("PIN proof required");
+ if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN");
+ try { await api.vault.lifecycle.unlock(pw); }
+ catch { throw new Error("PIN proof rejected"); }
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID });
+ txClearanceUntil = Date.now() + TX_CLEARANCE_MS;
return true;
});
+ // A panel opened while a dapp transaction is waiting for the PIN picks the
+ // request up here; one already open gets the "pinRequest" event instead.
+ api.onMessage("pinRequestPending", (_p, m) => {
+ fromPanel(m);
+ return pendingPinRequest ? { ...pendingPinRequest } : null;
+ });
+ ctx.pinNeeded = pinNeeded;
+ // Seal a plain blob left by an older build now, not when the panel next
+ // happens to open.
+ try { openPinBlob(api); } catch {}
api.onMessage("securityGet", (_p, m) => {
fromPanel(m);
@@ -2725,7 +2903,7 @@ function previewBytes(bytes, max = 400) {
let text = null;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {}
if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) {
- return text.length > max ? text.slice(0, max) + "…" : text;
+ return previewText(text, Math.max(max, 1500));
}
return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`;
}
@@ -2852,6 +3030,56 @@ function solInstructionRows(parsed) {
return rows;
}
+const ETH_RPC_PRECONNECT = new Set(["eth_chainId", "net_version", "eth_blockNumber"]);
+const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_accounts|eth_requestAccounts|eth_coinbase|eth_signTypedData|eth_newFilter|eth_newBlockFilter|eth_newPendingTransactionFilter|eth_uninstallFilter|eth_getFilterChanges|eth_getFilterLogs|eth_subscribe|eth_unsubscribe|eth_mining|eth_submit|eth_getWork)/;
+
+// EIP-2612 Permit, DAI-style permit, and Uniswap Permit2 (PermitSingle /
+// PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the
+// overlay rows that say who may spend what until when, or null when the
+// typed data is not a spending approval.
+function describePermit(td) {
+ const primary = String(td?.primaryType || "");
+ if (!/^Permit/.test(primary)) return null;
+ const msg = (td && typeof td.message === "object" && td.message) || {};
+ const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max
+ const big = (v) => { try { return BigInt(v); } catch { return null; } };
+ const amountText = (v) => { const b = big(v); return b === null ? String(v) : (b >= UNLIMITED ? "UNLIMITED (∞)" : b.toString() + " units"); };
+ const when = (v) => {
+ const b = big(v);
+ if (b === null) return String(v);
+ if (b >= 4102444800n) return "never expires"; // 2100-01-01 and beyond
+ try { return new Date(Number(b) * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; } catch { return b.toString(); }
+ };
+ const rows = [];
+ let risky = false;
+ const spender = msg.spender;
+ rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true });
+ if (!spender) risky = true;
+ const items = [];
+ if (primary === "Permit") {
+ // EIP-2612 has `value`; DAI has `allowed: true` (always unlimited).
+ if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n });
+ else items.push({ token: td.domain?.verifyingContract, amount: msg.value });
+ if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) });
+ } else {
+ const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : [])));
+ for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration });
+ if (msg.sigDeadline != null || msg.deadline != null) rows.push({ label: "Signature valid until", value: when(msg.sigDeadline ?? msg.deadline) });
+ }
+ items.slice(0, 10).forEach((it, i) => {
+ const b = big(it.amount);
+ if (b !== null && b >= UNLIMITED) risky = true;
+ rows.push({
+ label: items.length > 1 ? `Token #${i + 1}` : "Token",
+ value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`,
+ mono: true,
+ });
+ });
+ if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; }
+ if (!items.length) { rows.push({ label: "Token", value: "(could not read the approval — treat as unlimited)" }); risky = true; }
+ return { rows, risky };
+}
+
async function withOriginLock(origin, fn) {
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
pendingByOrigin.add(origin);
@@ -2922,6 +3150,9 @@ function registerPageMessages(api) {
const budget = perms[origin] && perms[origin].sendTx;
const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0;
if (budget && d.total <= remaining) {
+ // An allowance skips the overlay, not the PIN the user asked for on
+ // every transaction.
+ await requireDappTxPin(origin, "Bitcoin Cash payment");
const r = await rt.adapter.signAndBroadcast(plan);
budget.usedSats = (budget.usedSats | 0) + d.total;
api.storage.set("permissions", perms);
@@ -2948,6 +3179,7 @@ function registerPageMessages(api) {
});
const [action, ...flags] = pick.split("+");
if (action !== "send") throw new Error("user rejected");
+ await requireDappTxPin(origin, "Bitcoin Cash payment");
const cap = flags.find((f) => f.startsWith("cap="));
const capSats = cap ? Number(cap.slice(4)) : 0;
if (BCH_ALLOWANCES.includes(capSats)) {
@@ -2974,7 +3206,7 @@ function registerPageMessages(api) {
origin,
body: "Signing proves you control the address below. It moves no coins.",
rows: [
- { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
+ { label: "Message", value: previewText(message), mono: true },
{ label: "Address", value: rt.adapter.current().address, mono: true },
],
actions: [{ id: "sign", label: "Sign", primary: true }],
@@ -3135,6 +3367,7 @@ function registerPageMessages(api) {
actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
});
if (pick !== "sign") throw new Error("user rejected");
+ await requireDappTxPin(origin, "Tron transaction");
const sig = rt.adapter.signRawData(tx.raw_data_hex);
rememberSignedTron(decoded.txid);
return { ...tx, txID: decoded.txid, signature: [sig] };
@@ -3167,7 +3400,7 @@ function registerPageMessages(api) {
origin,
body: "Signing proves you control this address. It moves no coins.",
rows: [
- { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
+ { label: "Message", value: previewText(message), mono: true },
{ label: "Address", value: snap.address, mono: true },
],
actions: [{ id: "sign", label: "Sign", primary: true }],
@@ -3313,6 +3546,7 @@ function registerPageMessages(api) {
actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }],
});
if (pick !== "send") throw new Error("user rejected");
+ await requireDappTxPin(origin, "Ethereum transaction");
const r = await rt.adapter.signAndBroadcast(plan);
return { txid: r.txid };
});
@@ -3335,23 +3569,46 @@ function registerPageMessages(api) {
// truncated JSON preview of the message so the user has a fighting
// chance to spot phishing.
const dom = td.domain || {};
+ const snapTd = rt.adapter.snapshot();
+ // A signature for another chain is the standard way to get an approval
+ // the user believes is for a testnet or a sidechain. MetaMask refuses a
+ // domain whose chainId is not the active chain; so does Aegis.
+ if (dom.chainId != null && dom.chainId !== "") {
+ let domChain = null;
+ try { domChain = BigInt(dom.chainId); } catch {}
+ if (domChain === null || domChain !== BigInt(snapTd.chainId)) {
+ throw ethError(`typed data is for chain ${dom.chainId} but this site is connected on chain ${snapTd.chainId}`, 4901);
+ }
+ }
const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)";
- const messagePreview = JSON.stringify(td.message, null, 2);
- const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview;
+ const messagePreview = JSON.stringify(td.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || "";
+ const rows = [{ label: "Domain", value: domainSummary }];
+ if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true });
+ rows.push({ label: "Primary type", value: String(td.primaryType || "") });
+ // Off-chain approvals. A Permit / Permit2 signature moves no gas and
+ // shows no transaction, yet lets the spender take the tokens later — it
+ // is how most wallet drains happen now. Pull the spender, the amounts
+ // and the deadline out of the message and say what they mean.
+ const permit = describePermit(td);
+ if (permit) for (const r of permit.rows) rows.push(r);
+ rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true });
+ rows.push({ label: "Address", value: snapTd.address, mono: true });
+ const risky = !!(permit && permit.risky);
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
- title: "Sign typed data (EIP-712)?",
+ title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?",
origin,
- body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.",
- rows: [
- { label: "Domain", value: domainSummary },
- { label: "Primary type", value: String(td.primaryType || "") },
- { label: "Message", value: preview, mono: true },
- { label: "Address", value: rt.adapter.snapshot().address, mono: true },
- ],
- actions: [{ id: "sign", label: "Sign", primary: true }],
+ body: permit
+ ? (risky
+ ? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site."
+ : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.")
+ : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.",
+ rows,
+ actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
});
if (pick !== "sign") throw new Error("user rejected");
+ // A permit moves tokens as surely as a transaction does.
+ if (permit) await requireDappTxPin(origin, "token spending permit");
return rt.adapter.signTypedDataDigest(digest);
});
});
@@ -3472,6 +3729,13 @@ function registerPageMessages(api) {
const method = String(p && p.method || "");
const params = (p && p.params) || [];
if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through");
+ // The user's RPC endpoint is theirs (often a keyed, metered one). A site
+ // that has not connected gets the three calls every dapp makes before
+ // asking to connect and nothing else; signing, account and filter-state
+ // methods are never relayed, and broadcasting needs a connection.
+ const connected = ethConnectedFor(origin);
+ if (!connected && !ETH_RPC_PRECONNECT.has(method)) throw ethError("not connected — call eth_requestAccounts first", 4100);
+ if (ETH_RPC_NEVER.test(method)) throw ethError(`Aegis does not relay ${method}`, 4200);
return rt.adapter._client.call(method, params);
});
@@ -3560,6 +3824,7 @@ function registerPageMessages(api) {
actions: [{ id: "sign", label: "Sign", primary: true }],
});
if (pick !== "sign") throw new Error("user rejected");
+ await requireDappTxPin(origin, "Solana transaction");
return rt.adapter.signBytes(messageBytes);
});
});
@@ -3599,6 +3864,7 @@ function registerPageMessages(api) {
actions: [{ id: "send", label: "Sign & send", primary: true }],
});
if (pick !== "send") throw new Error("user rejected");
+ await requireDappTxPin(origin, "Solana transaction");
// Sign the exact message bytes and patch our slot. signMessage() ran
// the bytes through String(), so every signature was over "1,2,3,…"
// and the network rejected it. Partial signatures already in the wire
@@ -3698,7 +3964,10 @@ module.exports = {
body, rows,
actions: [{ id: "approve", label: "Sign", primary: true }],
});
- return { approved: pick === "approve" };
+ if (pick !== "approve") return { approved: false };
+ try { await requireDappTxPin(dappName, "Bitcoin Cash transaction (WizardConnect)"); }
+ catch (e) { api.log("wc sign:", e?.message || e); return { approved: false }; }
+ return { approved: true };
},
});
c.wc.onStateChange(() => emitState());
diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js
index 1fb92afd..f738bec5 100644
--- a/bundled-addons/aegis/panel.js
+++ b/bundled-addons/aegis/panel.js
@@ -306,7 +306,7 @@ function fiatSkeleton() {
// per-blob IV. The addon (main process) only handles the opaque blob; the
// panel never sends the raw PIN or the master password to it. The rate
// limiter is stored addon-side so reloading the panel cannot reset it.
-const PIN_ITERS = 200000;
+const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count
const PIN_MAX_FAILS = 5;
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
@@ -377,8 +377,13 @@ function bindIdleAutoLock() {
if (!s || s.phase !== "ready") return;
try {
state = await S.invoke("vaultLock");
- stripView = { mode: "coins", groupKey: null };
- render();
+ // Start the panel over rather than re-render it. Whatever was open
+ // goes with the page: a revealed key, an import form holding a seed
+ // phrase, a half-filled send, the password remembered for PIN
+ // enrolment. Painting a lock screen underneath left all of that on
+ // top of a "locked" wallet.
+ try { delete window.__aegisLastPw; } catch {}
+ location.reload();
} catch (e) { /* silent — user activity will retry */ }
}, mins * 60 * 1000);
};
@@ -1009,7 +1014,12 @@ function aegisConfirm(opts) {
};
const onKey = (e) => {
if (e.key === "Escape") { e.stopPropagation(); finish(false); }
- else if (e.key === "Enter") { e.stopPropagation(); finish(true); }
+ else if (e.key === "Enter") {
+ // Enter means "the focused button". It used to mean yes even with
+ // focus on Cancel, so Tab → Enter removed the wallet.
+ e.preventDefault(); e.stopPropagation();
+ finish(document.activeElement?.dataset?.ac !== "no");
+ }
};
document.addEventListener("keydown", onKey, true);
overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); });
@@ -1017,8 +1027,10 @@ function aegisConfirm(opts) {
e.stopPropagation();
finish(b.dataset.ac === "yes");
}));
+ // A destructive question opens on Cancel, like the host's own overlay.
const yes = overlay.querySelector('[data-ac="yes"]');
- if (yes) yes.focus();
+ const no = overlay.querySelector('[data-ac="no"]');
+ if (o.danger && no) no.focus(); else if (yes) yes.focus();
});
}
@@ -2208,7 +2220,7 @@ function openConsolidateModal() {
}
setBusy(true, "Sending…");
try {
- const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked });
+ const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
renderResult(res);
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
@@ -2327,7 +2339,7 @@ async function renderConsolidateInline(hostEl) {
const go = hostEl.querySelector("#inconGo");
go.disabled = true; go.textContent = "Sending…";
try {
- const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked });
+ const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
const okCount = res.results.filter((r) => r.ok).length;
const badCount = res.results.length - okCount;
const explorer = sel()?.explorerTx || "";
@@ -3440,6 +3452,11 @@ function renderLockScreen(phase) {
// as the panel navigates or reloads.
window.__aegisLastPw = pw;
setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000);
+ // Empty the field and let the form be rebuilt next time. It stayed
+ // filled behind the unlocked wallet, so after an idle lock or Sign out
+ // the password was sitting in the box for anyone to press Unlock.
+ try { $("gateUnlockPw").value = ""; } catch {}
+ body.dataset.mode = "";
body.dataset.forcePw = "";
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
@@ -3481,13 +3498,22 @@ function setupPinPad({ dots, keys, err, onComplete }) {
try { res = await onComplete(buf); }
finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false);
- if (res !== "ok") { buf = ""; paint(); }
+ // Always forget the digits. A pad that kept its six after "ok" was
+ // dead on the next lock and held the PIN in memory until then.
+ buf = ""; paint();
}
}
}));
// Keyboard fallback — some users prefer typing 6 digits fast.
const keyHandler = async (e) => {
if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; }
+ // Only while this pad is actually on screen, and never for keys typed
+ // into a field. The lock-screen pad stays in the DOM (hidden) after
+ // unlock, so six digits typed into the amount box counted as a PIN
+ // attempt — and five such amounts locked the PIN for 15 minutes.
+ if (dots.offsetParent === null) return;
+ const tgt = e.target;
+ if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = "";
if (/^[0-9]$/.test(e.key)) {
if (buf.length >= 6) return;
@@ -3498,7 +3524,7 @@ function setupPinPad({ dots, keys, err, onComplete }) {
try { res = await onComplete(buf); }
finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false);
- if (res !== "ok") { buf = ""; paint(); }
+ buf = ""; paint();
}
}
} else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); }
@@ -3509,6 +3535,7 @@ function setupPinPad({ dots, keys, err, onComplete }) {
function render() {
if (!state) return;
+ noteSelectedWallet();
const s = sel();
const ready = s && s.phase === "ready";
const phase = s?.phase;
@@ -4120,27 +4147,74 @@ function setUnit(u) {
const s = amountUnits();
unit = u;
applyUnitPicker();
- if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s);
+ // Exact conversion in the decimals of what is being sent. fmtBig() caps at
+ // 8 places and always used the chain's decimals, so 1 wei became "0" and a
+ // 6-decimal token amount shrank 1000× on a round trip.
+ if (s) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(s), amountDecimals()) : String(s);
updateSendFiatPreview();
}
-function amountUnits() {
- const raw = $("sendAmt").value.trim().replace(/,/g, "");
- if (!raw) return 0;
- // For SPL tokens the amount is a raw u64 string in the token's own
- // smallest unit — same BigInt-safe path SC uses.
- const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals();
- const bigDecimals = sendAsset != null || d > 15;
- if (unit === "small") {
- if (bigDecimals) return raw.replace(/\D+/g, "") || "0";
- return Math.round(Number(raw));
+function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); }
+
+// — pure functions, exercised by the sandbox harness.
+// What the user typed → { whole, frac } digit strings. A wallet must never
+// guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5,
+// and Number() accepted "1e3", "0x10" and "-0.5".
+function parseAmountText(text) {
+ let raw = String(text == null ? "" : text).trim().replace(/\s/g, "");
+ if (!raw) return { empty: true };
+ if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) {
+ raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
+ } else if (/^\d{1,3},\d{3}$/.test(raw)) {
+ return { error: `"${raw}" could mean ${raw.replace(",", "")} or ${raw.replace(",", ".")} — write it without the comma, or with a dot` };
+ } else if (/^\d*,\d+$/.test(raw)) {
+ raw = raw.replace(",", "."); // decimal comma: 0,5
}
+ if (!/^(\d+\.?\d*|\.\d+)$/.test(raw)) return { error: "Enter a plain number, like 0.5" };
const [w, f = ""] = raw.split(".");
- const frac = (f + "0".repeat(d)).slice(0, d);
- if (bigDecimals) {
- const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0");
- return total.toString();
+ return { whole: (w || "0").replace(/^0+(?=\d)/, ""), frac: f };
+}
+// → smallest units as a decimal string, or { error }. `unit` is "big" (coins)
+// or "small" (sats / wei / raw token units).
+function amountToUnits(text, decimalsN, unit) {
+ const p = parseAmountText(text);
+ if (p.empty) return { units: "0", empty: true };
+ if (p.error) return { error: p.error };
+ const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
+ if (unit === "small") {
+ if (/[1-9]/.test(p.frac)) return { error: "The smallest unit cannot have decimals" };
+ return { units: BigInt(p.whole).toString() };
}
- return Number(w || 0) * Math.pow(10, d) + Number(frac || 0);
+ if (/[1-9]/.test(p.frac.slice(d))) return { error: `At most ${d} decimal place${d === 1 ? "" : "s"} here` };
+ const frac = (p.frac + "0".repeat(d)).slice(0, d);
+ return { units: (BigInt(p.whole) * (10n ** BigInt(d)) + BigInt(frac || "0")).toString() };
+}
+// Exact inverse for filling the field: units → "1.5" with no grouping.
+function unitsToDecimalText(units, decimalsN) {
+ const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
+ const s = String(units).replace(/^0+(?=\d)/, "");
+ if (!/^\d+$/.test(s)) return "";
+ if (d === 0) return s;
+ const pad = s.padStart(d + 1, "0");
+ const frac = pad.slice(pad.length - d).replace(/0+$/, "");
+ return pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
+}
+//
+
+// Why the amount in the field is not usable, or null. Set by amountUnits().
+let amountError = null;
+function amountUnits() {
+ amountError = null;
+ const d = amountDecimals();
+ const r = amountToUnits($("sendAmt").value, d, unit);
+ if (r.error) { amountError = r.error; return 0; }
+ if (r.empty) return 0;
+ // SPL tokens and 18/24-decimal coins travel as decimal strings; the 8–9
+ // decimal chains keep their Number contract with the host, bounded so a
+ // value past 2^53 is refused instead of silently rounded.
+ const bigDecimals = sendAsset != null || d > 15;
+ if (bigDecimals) return r.units;
+ if (BigInt(r.units) > BigInt(Number.MAX_SAFE_INTEGER)) { amountError = "That amount is too large"; return 0; }
+ return Number(r.units);
}
// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere).
function balanceSum(b) {
@@ -4425,50 +4499,119 @@ function updateSendFiatPreview() {
const el = $("sendAmtFiat"); if (!el) return;
const s = sel(); if (!s || sendAsset) { el.hidden = true; return; }
const units = amountUnits();
- if (!units || !state?.prices?.enabled) { el.hidden = true; return; }
+ if (!units || amountError || !state?.prices?.enabled) { el.hidden = true; return; }
const usd = usdOf(chain(), units, decimals());
const txt = fmtFiat(usd);
el.textContent = txt ? "≈ " + txt : "";
el.hidden = !txt;
}
-function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); }
+function schedulePlan() {
+ // The summary on screen is stale from the first keystroke: Send stays off
+ // until a plan for exactly what is in the form has come back.
+ lastPlan = null; lastPlanReq = null;
+ const b = $("sendBtn"); if (b) b.disabled = true;
+ clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250);
+}
+// The request a plan was made for. Send submits THIS, never a fresh read of
+// the form, so what is signed is what the summary showed.
+let lastPlanReq = null;
+let planSeq = 0;
+function currentSendReq() {
+ const amount = amountUnits();
+ const base = { walletId: state?.selectedWalletId || null, to: $("sendTo").value.trim() };
+ if (sendAsset) return { ...base, mint: sendAsset.mint, amount };
+ return {
+ ...base, amount,
+ feeRate: chain() === "bch" ? Number($("feeRate").value) : undefined,
+ sendMax,
+ memo: chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "",
+ };
+}
+// Selecting another wallet invalidates everything the Send form worked out.
+// The fields used to survive the switch: the button stayed live on the old
+// wallet's plan, Max swept the new wallet under the old summary, and a
+// number typed in sats was re-read as wei.
+let sendFormWalletId = null;
+function noteSelectedWallet() {
+ const id = state?.selectedWalletId || null;
+ if (id === sendFormWalletId) return;
+ const prev = (state?.wallets || []).find((w) => w.id === sendFormWalletId);
+ const cur = (state?.wallets || []).find((w) => w.id === id);
+ const first = sendFormWalletId === null;
+ sendFormWalletId = id;
+ if (first) return;
+ lastPlan = null; lastPlanReq = null; planSeq++;
+ clearTimeout(planTimer);
+ sendAsset = null;
+ if (sendMax) {
+ sendMax = false;
+ try { $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; $("sendAmt").value = ""; } catch {}
+ }
+ // Another coin means the address and the number mean something else.
+ if (!prev || !cur || prev.chain !== cur.chain || prev.network !== cur.network) {
+ try { $("sendTo").value = ""; $("sendAmt").value = ""; if ($("sendMemo")) $("sendMemo").value = ""; } catch {}
+ unit = "big";
+ try { applyUnitPicker(); } catch {}
+ }
+ try {
+ $("sendBtn").disabled = true;
+ $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
+ $("sendMsg").hidden = true;
+ } catch {}
+ if ($("sendTo")?.value && $("sendAmt")?.value) schedulePlan();
+}
async function updatePlan() {
+ const seq = ++planSeq;
const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true;
- lastPlan = null; $("sendBtn").disabled = true;
+ lastPlan = null; lastPlanReq = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = "";
- if (!to || (!sendMax && !amountUnits())) return;
+ const req = currentSendReq();
+ if (amountError && !sendMax) { msg.className = "msg err"; msg.textContent = amountError; msg.hidden = false; return; }
+ if (!to || (!sendMax && !req.amount)) return;
try {
if (sendAsset) {
// SPL token flow — amount is raw units of the token's decimals.
- const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() });
- lastPlan = { _token: true, ...p };
+ const p = await S.invoke("planTokenSend", { mint: req.mint, to, amount: req.amount });
+ if (seq !== planSeq) return; // a newer plan is on its way
+ lastPlan = { _token: true, ...p }; lastPlanReq = req;
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
$("sendBtn").disabled = false;
return;
}
- const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
- const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "";
- const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax, memo });
- lastPlan = p;
+ const p = await S.invoke("planSend", { to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
+ if (seq !== planSeq) return; // a newer plan is on its way
+ lastPlan = p; lastPlanReq = req;
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
$("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker();
$("sumFee").textContent = chain() === "bch"
? fmtSmall(p.fee) + " " + smallUnitLabel()
: fmtBig(p.fee) + " " + ticker();
$("sumTotal").textContent = fmtBig(p.total) + " " + ticker();
- if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value);
+ if (sendMax) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(p.recipients[0].value), amountDecimals()) : String(p.recipients[0].value);
$("sendBtn").disabled = false;
} catch (e) {
+ if (seq !== planSeq) return;
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
}
}
$("sendBtn").addEventListener("click", async () => {
- if (!lastPlan) return;
+ if (!lastPlan || !lastPlanReq) return;
const msg = $("sendMsg"); msg.hidden = true;
+ // The form must still say what the summary was computed for. Max rewrites
+ // the amount field itself, so it is compared without the amount.
+ const now = currentSendReq();
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+ const strip = (r) => (r.sendMax ? { ...r, amount: null } : r);
+ if (!same(strip(now), strip(lastPlanReq))) {
+ await updatePlan();
+ if (lastPlan) { msg.className = "msg err"; msg.textContent = "The form changed — check the updated summary, then press Send again."; msg.hidden = false; }
+ return;
+ }
+ const req = lastPlanReq;
// PIN gate. Whether a transaction needs one is the policy's call, not a
// single flag's — and if the user only asked for a PIN at startup, this
// check passes without a prompt.
@@ -4477,16 +4620,22 @@ $("sendBtn").addEventListener("click", async () => {
}
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try {
- const isToken = sendAsset && lastPlan._token;
- const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
- const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "";
+ const isToken = !!(req.mint && lastPlan._token);
const r = isToken
- ? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() })
- : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax, memo });
+ ? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount })
+ : await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
+ // A broadcast that returned no txid is still a broadcast: never report
+ // it as a failure and leave a filled form inviting a second send.
+ const txid = r && typeof r.txid === "string" ? r.txid : "";
msg.className = "msg ok";
- msg.innerHTML = `Sent. ${esc(r.txid.slice(0, 16))}…`;
- msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid)));
+ if (txid) {
+ msg.innerHTML = `Sent. ${esc(txid.slice(0, 16))}…`;
+ msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, txid)));
+ } else {
+ msg.textContent = "Sent. The network did not return a transaction id — check History before sending again.";
+ }
msg.hidden = false;
+ lastPlanReq = null;
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false;
if ($("sendMemo")) $("sendMemo").value = "";
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
@@ -4741,7 +4890,10 @@ for (const [id, key] of PIN_ON_FIELDS) {
securityState = await S.invoke("securitySet", { pinOn: { [key]: want } });
// Ticking a trigger should not fire it retroactively: the user is
// sitting in Settings having just proved whatever got them here.
- if (want) { try { await S.invoke("pinGateSatisfied"); } catch {} }
+ // The host only records a gate against proof it verified, so this
+ // works when the password is still at hand (just unlocked); otherwise
+ // the new trigger simply asks once at its next occasion.
+ if (want && window.__aegisLastPw) { try { await S.invoke("pinGateSatisfied", { masterPassword: window.__aegisLastPw }); } catch {} }
renderGeneralSecurity();
} catch (e) {
box.checked = !want;
@@ -4998,9 +5150,25 @@ async function pinGate(event, subtitle) {
try { st = await S.invoke("pinGateStatus", { event }); }
catch { st = { needPin: true, reason: "unknown" }; }
if (!st.needPin) return true;
- const ok = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
- if (ok) { try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next time */ } }
- return ok;
+ // verifyPinInteractively resolves to what the PIN unwrapped — the vault
+ // master password — and the host checks that itself before it records the
+ // gate or lets a transaction through. A PIN the host could not verify
+ // does not count, so a failure here is a failed gate.
+ const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
+ if (!proof) return false;
+ try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); }
+ catch { return false; }
+ return true;
+}
+// A dapp transaction is waiting on the PIN ("ask on every transaction").
+// The host cannot draw a PIN pad, so it asks the panel: prove the PIN here
+// and the pending transaction goes through.
+async function answerPinRequest(req) {
+ if (!req || pinGateBlocked) return;
+ const where = String(req.origin || "A site").slice(0, 80);
+ const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`);
+ if (!proof) return;
+ try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* host keeps waiting, then refuses */ }
}
const PIN_GATE_COPY = {
restart: "Theseus restarted — confirm your PIN to use this wallet.",
@@ -5040,9 +5208,10 @@ async function pinGateOnLoad() {
// Keep asking until it is satisfied. Cancelling does not open the wallet;
// it leaves the door shut with a button to try again, which is the only
// honest outcome for "a PIN is required here".
+ let proof = null;
for (;;) {
- const ok = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
- if (ok) break;
+ proof = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
+ if (proof) break;
const again = await aegisConfirm({
title: "PIN required",
icon: "🔒",
@@ -5052,7 +5221,7 @@ async function pinGateOnLoad() {
});
if (!again) return; // stays blocked; door remains shut
}
- try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next load */ }
+ try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* re-asks next load */ }
pinGateBlocked = false;
render();
}
@@ -5219,9 +5388,11 @@ async function verifyPinInteractivelyOnce(subtitle) {
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("no PIN configured");
- await pinDecryptMaster(pin, blob);
+ const master = await pinDecryptMaster(pin, blob);
await S.invoke("pinFailReset").catch(() => {});
- done(true);
+ // Truthy for every existing caller; the gate hands it to the host
+ // as proof (see pinGate).
+ done(master || true);
return "ok";
} catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
@@ -5586,6 +5757,7 @@ S.on("state", (s) => {
// tab behind the door either.
if (tab === "settings" && !pinGateBlocked) fillSettings();
});
+S.on("pinRequest", (req) => { answerPinRequest(req); });
(async () => {
// Load security + session state first so the very first render() knows
// whether to paint the PIN pad on the lock screen and what idle-lock
@@ -5605,6 +5777,8 @@ S.on("state", (s) => {
await pinGateOnLoad();
render();
bindIdleAutoLock();
+ // Opened because a dapp transaction is waiting for the PIN? Answer it.
+ try { answerPinRequest(await S.invoke("pinRequestPending")); } catch {}
})();
// Persistent footer: aegis.x brand link + version marker + update check.