Aegis: the PIN is enforced by the host, and every spend is confirmed there
PIN - The PIN blob wraps the vault master password under six digits and sat in plain add-on storage, so a copy of the profile reduced the master password to a million offline PBKDF2 guesses. It is sealed with the OS keystore before it is stored; a plain blob from an older build is sealed on first read. New blobs use 600k iterations. - "Ask for PIN on every transaction" was decided by the host and enforced by nobody: `send` never checked it and dapp transactions had no PIN step. A gate is now cleared only by the master password the PIN unwraps, verified against the vault, which also opens a single-use transaction clearance. Panel sends consume one; dapp transactions ask the open panel and wait. Spending and signing - Consolidate emptied wallets on the panel's confirmation alone, defaulted to every sibling when no list was sent, and swept into whatever was selected at click time. It now needs explicit sources and the previewed destination, and shows the whole batch on the host overlay. - Typed data for a chain other than the connected one is refused. Permit and Permit2 signatures name the spender, tokens, amounts and expiry, and an unlimited one gets the danger action. Previews are no longer cut at 600/400 characters without saying so. - eth.rpc relayed any eth_* call for sites that never connected. - The WizardConnect overlay lists the tokens being spent and received. Send form - "0,5" was read as 5: the parser deleted commas. Amounts are parsed exactly; a decimal comma is a decimal, ambiguous or non-numeric input is refused, extra decimals are an error instead of being dropped. - Send submits the request the summary was computed for, never a fresh read of the form, and stays off while a plan is pending or stale. - Switching wallet resets the form instead of leaving a live button on the previous wallet's plan. - The unlock field kept the master password after unlocking; the PIN pad kept its digits and kept counting keystrokes typed elsewhere as PIN attempts; an idle lock left a revealed key or seed form on screen. - Enter confirmed a dialog even with focus on Cancel.
This commit is contained in:
parent
9e7e9d5e8b
commit
f27f3d27c9
2 changed files with 525 additions and 82 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
||||
// <amount-parser> — 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 : "");
|
||||
}
|
||||
// </amount-parser>
|
||||
|
||||
// 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. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
|
||||
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid)));
|
||||
if (txid) {
|
||||
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(txid)}">${esc(txid.slice(0, 16))}…</a>`;
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue