diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 2158551c..0cdfa9e6 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -3323,35 +3323,100 @@ function parseSolMessage(msg, ourPub) { // Overlay rows: System transfers and SPL transfer/approve/set-authority are // decoded; everything else is named by program so the user at least sees // how much of the transaction Aegis could not read. -function solInstructionRows(parsed) { +// Overlay rows for a Solana message, plus what makes it risky. The +// network fee is shown in full: a dapp-added ComputeBudget price is paid on +// top of the base fee and used to be invisible ("program ComputeB…: not +// decoded"), so a transaction could burn the balance in priority fees. +// `rows.risks` lists what deserves the danger button; `rows.feeLamports` +// is the most this transaction can cost in fees. +const SOL_COMPUTE_BUDGET = "ComputeBudget111111111111111111111111111111"; +const SOL_SYSTEM = "11111111111111111111111111111111"; +function solInstructionRows(parsed, balanceLamports = null) { const b58 = (b) => ctx.d.base58check.encodeBase58(b); const u64le = (d, o) => { let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(d[o + i] || 0); return v; }; + const u32le = (d, o) => (d[o] | (d[o + 1] << 8) | (d[o + 2] << 16) | (d[o + 3] << 24)) >>> 0; + const ours = parsed.ourIndex >= 0 ? b58(parsed.keys[parsed.ourIndex]) : null; const rows = []; + const risks = []; + let unitLimit = null, unitPrice = 0n, undecoded = 0, budgetIxs = 0; const lines = parsed.instructions.map((ix, i) => { - if (ix.programIdx >= parsed.keys.length) return `${i + 1}. program from a lookup table (not decoded)`; + if (ix.programIdx >= parsed.keys.length) { undecoded++; return `${i + 1}. program from a lookup table (not decoded)`; } const progB58 = b58(parsed.keys[ix.programIdx]); const acct = (n) => { const idx = ix.accounts[n]; return idx == null ? "?" : (idx < parsed.keys.length ? b58(parsed.keys[idx]) : `lookup-table account #${idx}`); }; const d = ix.data; - if (progB58 === "11111111111111111111111111111111" && d.length >= 12 && d[0] === 2 && d[1] === 0 && d[2] === 0 && d[3] === 0) { - return `${i + 1}. System transfer ${fmtValue(u64le(d, 4).toString(), 9)} SOL → ${acct(1)}`; + const n = i + 1; + if (progB58 === SOL_COMPUTE_BUDGET && d.length >= 1) { + budgetIxs++; + if (d[0] === 2 && d.length >= 5) { unitLimit = u32le(d, 1); return `${n}. Compute limit ${unitLimit.toLocaleString("en-US")} units`; } + if (d[0] === 3 && d.length >= 9) { unitPrice = u64le(d, 1); return `${n}. Priority fee ${unitPrice.toString()} micro-lamports per unit`; } + if (d[0] === 1) return `${n}. Heap frame request`; + if (d[0] === 4) return `${n}. Account data limit`; + return `${n}. Compute budget (${d.length} bytes, not decoded)`; } - if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 9) { - if (d[0] === 3) return `${i + 1}. Token transfer ${u64le(d, 1).toString()} units → ${acct(1)}`; - if (d[0] === 12) return `${i + 1}. Token transfer ${u64le(d, 1).toString()} units → ${acct(2)}`; - if (d[0] === 4) return `${i + 1}. Token APPROVE: delegate ${acct(1)} may spend ${u64le(d, 1).toString()} units`; + if (progB58 === SOL_SYSTEM && d.length >= 4) { + const t = u32le(d, 0); + if (t === 2 && d.length >= 12) return `${n}. System transfer ${fmtTokenAmount(u64le(d, 4), 9)} SOL → ${acct(1)}`; + if (t === 0 && d.length >= 12) return `${n}. Create account ${acct(1)} funded with ${fmtTokenAmount(u64le(d, 4), 9)} SOL`; + if (t === 11 && d.length >= 12) return `${n}. System transfer ${fmtTokenAmount(u64le(d, 4), 9)} SOL → ${acct(2)}`; + if (t === 1 || t === 10) { + risks.push("reassigns an account to another program"); + return `${n}. ASSIGN account ${acct(0)} to another program — it hands over control of that account`; + } + if (t === 4) { + risks.push("uses a durable nonce: the signed transaction never expires and can be sent whenever the site likes"); + return `${n}. Advance durable nonce — this signature stays valid indefinitely`; + } + if (t === 8 || t === 9) return `${n}. Allocate account space`; + undecoded++; + return `${n}. System instruction ${t} (not decoded)`; } - if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 1 && d[0] === 6) return `${i + 1}. Token SET AUTHORITY on ${acct(0)} — hands the account to someone else`; - return `${i + 1}. program ${progB58.slice(0, 8)}…: ${d.length} bytes, ${ix.accounts.length} account${ix.accounts.length === 1 ? "" : "s"} (not decoded)`; + if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 1) { + const t = d[0]; + if (t === 3 && d.length >= 9) return `${n}. Token transfer ${u64le(d, 1).toString()} units → ${acct(1)}`; + if (t === 12 && d.length >= 10) return `${n}. Token transfer ${fmtTokenAmount(u64le(d, 1), d[9])} → ${acct(2)}`; + if ((t === 4 && d.length >= 9) || (t === 13 && d.length >= 10)) { + risks.push("approves another account to spend tokens"); + const amt = t === 13 ? fmtTokenAmount(u64le(d, 1), d[9]) : u64le(d, 1).toString() + " units"; + return `${n}. Token APPROVE: delegate ${acct(t === 13 ? 2 : 1)} may spend ${amt}`; + } + if (t === 6) { risks.push("changes a token account's authority"); return `${n}. Token SET AUTHORITY on ${acct(0)} — hands the account to someone else`; } + if (t === 9) { + const dest = acct(1); + if (dest !== ours) risks.push("closes a token account and sends its SOL to someone else"); + return `${n}. CLOSE token account ${acct(0)}; its SOL goes to ${dest === ours ? "you" : dest}`; + } + if (t === 5) return `${n}. Revoke a token delegate`; + if (t === 8 || t === 15) return `${n}. BURN tokens from ${acct(0)}`; + if (t === 1 || t === 16 || t === 18) return `${n}. Initialize token account ${acct(0)}`; + undecoded++; + return `${n}. Token instruction ${t} (not decoded)`; + } + undecoded++; + return `${n}. program ${progB58.slice(0, 8)}…: ${d.length} bytes, ${ix.accounts.length} account${ix.accounts.length === 1 ? "" : "s"} (not decoded)`; }); rows.push({ label: parsed.instructions.length === 1 ? "Instruction" : "Instructions", value: lines.join("\n") || "(none)", mono: true }); + // Fee: 5000 lamports per signature, plus limit × price. With no explicit + // limit the runtime allows 200k units per non-budget instruction, up to 1.4M. + const limit = BigInt(unitLimit != null ? Math.min(unitLimit, 1_400_000) : Math.min(1_400_000, 200_000 * Math.max(1, parsed.instructions.length - budgetIxs))); + const priority = (limit * unitPrice + 999_999n) / 1_000_000n; + const feeLamports = 5000n * BigInt(Math.max(1, parsed.numRequiredSigs)) + priority; + rows.push({ label: "Network fee (max)", value: `${fmtTokenAmount(feeLamports, 9)} SOL${priority > 0n ? ` (incl. ${fmtTokenAmount(priority, 9)} SOL priority fee set by the site)` : ""}`, strong: priority > 0n }); + const bal = balanceLamports != null ? BigInt(balanceLamports) : null; + if (priority > 50_000_000n || (bal != null && bal > 0n && feeLamports * 10n > bal)) { + risks.push(`carries an unusually high network fee (${fmtTokenAmount(feeLamports, 9)} SOL)`); + } if (parsed.version === 0) { rows.push({ label: "Format", value: `v0 · ${parsed.lookupTables} address-lookup table${parsed.lookupTables === 1 ? "" : "s"} (accounts inside them are not shown)` }); } const others = parsed.numRequiredSigs - 1; rows.push({ label: "Signers", value: others ? `${parsed.numRequiredSigs} — you (slot #${parsed.ourIndex}) + ${others} other${others === 1 ? "" : "s"}` : "1 — you" }); + if (risks.length) rows.unshift({ label: "Warning", value: [...new Set(risks)].map((r) => "• This transaction " + r + ".").join("\n"), strong: true }); + rows.risks = risks; + rows.undecoded = undecoded; + rows.feeLamports = feeLamports; return rows; } @@ -3714,6 +3779,8 @@ function registerPageMessages(api) { for (const c of decoded.contracts) { if (!c.owner) throw new Error(`cannot read the owner of ${c.typeName}; refusing to sign`); if (c.owner !== me) throw new Error(`transaction owner ${c.owner} is not this wallet`); + // A known method with missing arguments would show "undefined". + if (c.call && c.call.malformed) throw new Error(`the ${c.call.name} call is truncated; refusing to sign`); } return withOriginLock(origin, async () => { const rows = []; @@ -3734,18 +3801,26 @@ function registerPageMessages(api) { else if (call && call.name === "transferFrom") rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.unlimited ? "UNLIMITED" : String(call.amount)} units`, strong: true }); else rows.push({ label: "Call", value: call && call.selector ? `unknown method 0x${call.selector} (${c.data.length / 2} bytes, not decoded)` : "(no call data)", mono: true }); if (c.callValue) rows.push({ label: "TRX attached", value: `${fmtTrx(Number(c.callValue))} TRX` }); + if (c.tokenValue) rows.push({ label: "TRC10 attached", value: `${String(c.tokenValue)} units of token #${String(c.tokenId ?? "?")}`, strong: true }); + } else { + rows.push({ label: "Details", value: "not decoded — Aegis cannot show what this transaction does", strong: true }); } }); if (decoded.feeLimit != null) rows.push({ label: "Fee limit", value: `${fmtTrx(Number(decoded.feeLimit))} TRX` }); - if (decoded.memo) rows.push({ label: "Memo", value: decoded.memo.slice(0, 200), mono: true }); + if (decoded.memo) rows.push({ label: "Memo", value: previewText(decoded.memo, 200), mono: true }); + if (decoded.expiration) { + const left = decoded.expiration - Date.now(); + rows.push({ label: "Valid until", value: new Date(decoded.expiration).toISOString().replace("T", " ").slice(0, 16) + " UTC" + (left > 24 * 3600e3 ? " — unusually long; the site can broadcast it any time until then" : "") }); + } rows.push({ label: "Tx ID", value: decoded.txid, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` }); - const risky = decoded.contracts.some((c) => c.dangerous || (c.call && c.call.unlimited)); + const risky = decoded.contracts.some((c) => c.dangerous || c.undecoded || (c.tokenValue && c.tokenValue > 0n) || (c.call && c.call.unlimited)) + || (decoded.expiration && decoded.expiration - Date.now() > 24 * 3600e3); const pick = await api.approvalModal({ title: "Sign a Tron transaction?", origin, body: risky - ? "WARNING: this transaction hands another account unlimited or permanent control over funds. Only sign if you fully trust this site." + ? "WARNING: this transaction does something Aegis flags as risky (open-ended control over funds, tokens sent along with a call, a type Aegis cannot read, or a very long validity). Only sign if you fully trust this site." : "Decoded from the bytes that will be signed. Check the type, amount and destination.", rows, actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], @@ -3853,18 +3928,30 @@ function registerPageMessages(api) { // shows the decoded text. const bytes = bytesFromDappMessage(p && p.message); if (bytes.length > 16384) throw new Error("message too long"); + // Exactly 32 bytes that are not readable text is a hash, and contracts + // accept a personal_sign over a hash as approval of whatever it hashes: + // a Gnosis Safe takes it for its safeTxHash (v > 30), many order books + // and relayers verify toEthSignedMessageHash(orderHash). That is not a + // login message, so it gets the danger button and the PIN. + let textual = false; + try { new TextDecoder("utf-8", { fatal: true }).decode(bytes); textual = !INVISIBLE_RE.test(Buffer.from(bytes).toString("utf8")); } catch {} + INVISIBLE_RE.lastIndex = 0; + const hashLike = bytes.length === 32 && !textual; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ - title: "Sign an Ethereum message?", + title: hashLike ? "Sign a 32-byte hash?" : "Sign an Ethereum message?", origin, - body: "Signing proves you control this address. It moves no ETH.", + body: hashLike + ? "WARNING: this is not a readable message but a 32-byte hash. Contracts such as Safe wallets and order books accept a signature over a hash as approval of whatever it stands for — a transaction or a trade Aegis cannot show you. Only sign if you know exactly what this hash is." + : "Signing proves you control this address. It moves no ETH.", rows: [ { label: "Message", value: previewBytes(bytes), mono: true }, { label: "Address", value: rt.adapter.snapshot().address, mono: true }, ], - actions: [{ id: "sign", label: "Sign", primary: true }], + actions: [{ id: "sign", label: hashLike ? "Sign anyway" : "Sign", primary: !hashLike, danger: hashLike }], }); if (pick !== "sign") throw new Error("user rejected"); + if (hashLike) await requireDappTxPin(origin, "signature over a 32-byte hash"); return rt.adapter.signMessage(bytes); }); }); @@ -3895,6 +3982,7 @@ function registerPageMessages(api) { const rows = [{ label: "To", value: ethLib.eip55(plan.recipients[0].to), mono: true }]; let body = `This site is asking your wallet to send ${ticker}.`; let risky = false; + let approval = false; if (isCall) { let code = "0x"; try { code = await rt.adapter._client.call("eth_getCode", [plan.recipients[0].to, "latest"]); } catch {} @@ -3902,9 +3990,21 @@ function registerPageMessages(api) { const call = ethLib.decodeCalldata(data); if (call && call.name === "transfer") { rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : call.amount.toString()} token units to ${call.to}`, strong: true }); - } else if (call && (call.name === "approve" || call.name === "increaseAllowance")) { + } else if (call && (call.name === "approve" || call.name === "increaseAllowance" || call.name === "increaseApproval")) { risky = !!call.unlimited; - rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString()} token units`, strong: true }); + approval = true; + rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString() + " token units (for an NFT contract: token #" + call.amount.toString() + ")"}`, strong: true }); + } else if (call && call.name === "permit2Approve") { + risky = true; + approval = true; + rows.push({ label: "Call", value: `Permit2 approve: ${call.spender} may spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString() + " units"} of token ${call.token} until ${call.deadline ? new Date(Number(call.deadline) * 1000).toISOString().slice(0, 10) : "?"}`, strong: true }); + } else if (call && (call.name === "safeTransferFrom" || call.name === "safeTransferFrom1155")) { + rows.push({ label: "Call", value: `transfer NFT/token #${call.tokenId?.toString()}${call.amount != null ? " × " + call.amount.toString() : ""}: ${call.from} → ${call.to}`, strong: true }); + } else if (call && call.name === "safeBatchTransferFrom") { + risky = true; + rows.push({ label: "Call", value: `batch transfer of several tokens: ${call.from} → ${call.to} (items not decoded)`, strong: true }); + } else if (call && call.name === "multicall") { + rows.push({ label: "Call", value: `multicall: several calls bundled together (not decoded, ${call.bytes} bytes)`, mono: true }); } else if (call && call.name === "transferFrom") { rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.amount.toString()} units`, strong: true }); } else if (call && call.name === "setApprovalForAll") { @@ -3918,7 +4018,13 @@ function registerPageMessages(api) { } body = risky ? "WARNING: this call grants another address open-ended control over your tokens. Only sign if you fully trust this site." - : "This transaction calls a contract. Aegis decoded what it could — check the destination, the call and the value."; + : approval + ? "This call lets another address spend your tokens up to the amount shown. Check the spender and the amount." + : "This transaction calls a contract. Aegis decoded what it could — check the destination, the call and the value."; + } + if (plan.feeWarning) { + risky = true; + rows.unshift({ label: "Warning", value: plan.feeWarning, strong: true }); } rows.push({ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ${ticker}`, strong: true }); rows.push({ label: "Fee (est.)", value: `${fmtValue(plan.feeEstimate, meta.decimals)} ${ticker} · max ${fmtValue(plan.fee, meta.decimals)} ${ticker}` }); @@ -4215,15 +4321,17 @@ function registerPageMessages(api) { if (parsed.ourIndex >= parsed.numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${parsed.ourIndex}, requiredSigs ${parsed.numRequiredSigs})`); return withOriginLock(origin, async () => { const snap = rt.adapter.snapshot(); - const rows = solInstructionRows(parsed); + const solBal = (() => { const b = snap.balance; const v = b && typeof b === "object" ? (b.confirmed ?? b.lamports) : b; try { return v != null ? BigInt(v) : null; } catch { return null; } })(); + const rows = solInstructionRows(parsed, solBal); rows.push({ label: "Address", value: snap.address, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }); const pick = await api.approvalModal({ title: "Sign a Solana transaction?", origin, - body: "The site built this transaction and will broadcast it itself — signing it is the same as sending it.", + body: "The site built this transaction and will broadcast it itself — signing it is the same as sending it." + + (rows.risks.length ? " Read the warning: this is not an ordinary payment." : rows.undecoded ? " Parts marked 'not decoded' are programs Aegis cannot read." : ""), rows, - actions: [{ id: "sign", label: "Sign", primary: true }], + actions: [{ id: "sign", label: rows.risks.length ? "Sign anyway" : "Sign", primary: !rows.risks.length, danger: rows.risks.length > 0 }], }); if (pick !== "sign") throw new Error("user rejected"); await requireDappTxPin(origin, "Solana transaction"); @@ -4243,7 +4351,7 @@ function registerPageMessages(api) { const wireB64 = String(p && p.wireB64 || ""); if (!wireB64) throw new Error("wireB64 required (full serialized transaction)"); const wire = new Uint8Array(Buffer.from(wireB64, "base64")); - const { sigsStart, messageBytes } = splitSolWire(wire); + const { sigCount, sigsStart, messageBytes } = splitSolWire(wire); // Legacy and v0 messages both parse (v0 used to be read with its // version byte as the header, so the signer lookup was garbage); our // key must be a required signer. @@ -4252,18 +4360,23 @@ function registerPageMessages(api) { const { numRequiredSigs, ourIndex } = parsed; if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys"); if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`); + // Our signature is written at sigsStart + ourIndex*64; with fewer slots + // than signers that would overwrite message bytes. + if (sigCount !== numRequiredSigs) throw new Error(`the transaction has ${sigCount} signature slots for ${numRequiredSigs} signers`); return withOriginLock(origin, async () => { const snap = rt.adapter.snapshot(); - const rows = solInstructionRows(parsed); + const solBal = (() => { const b = snap.balance; const v = b && typeof b === "object" ? (b.confirmed ?? b.lamports) : b; try { return v != null ? BigInt(v) : null; } catch { return null; } })(); + const rows = solInstructionRows(parsed, solBal); rows.push({ label: "Address", value: snap.address, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }); const pick = await api.approvalModal({ title: "Sign + send a Solana transaction?", origin, - body: "The site built this transaction. Check every instruction — anything marked 'not decoded' is a program Aegis cannot read.", + body: "The site built this transaction. Check every instruction — anything marked 'not decoded' is a program Aegis cannot read." + + (rows.risks.length ? " Read the warning: this is not an ordinary payment." : ""), rows, - actions: [{ id: "send", label: "Sign & send", primary: true }], + actions: [{ id: "send", label: rows.risks.length ? "Sign & send anyway" : "Sign & send", primary: !rows.risks.length, danger: rows.risks.length > 0 }], }); if (pick !== "send") throw new Error("user rejected"); await requireDappTxPin(origin, "Solana transaction"); diff --git a/bundled-addons/aegis/lib/chain-eth.js b/bundled-addons/aegis/lib/chain-eth.js index 1bc0a78f..ceef0105 100644 --- a/bundled-addons/aegis/lib/chain-eth.js +++ b/bundled-addons/aegis/lib/chain-eth.js @@ -205,7 +205,20 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { a22cb465: { name: "setApprovalForAll", args: ["operator", "approved"] }, "39509351": { name: "increaseAllowance", args: ["spender", "amount"] }, d505accf: { name: "permit", args: ["owner", "spender", "value", "deadline"] }, + d73dd623: { name: "increaseApproval", args: ["spender", "amount"] }, + // Uniswap Permit2 on-chain approve(token, spender, uint160 amount, uint48 expiration) + "87517c45": { name: "permit2Approve", args: ["token", "spender", "amount", "deadline"] }, + "42842e0e": { name: "safeTransferFrom", args: ["from", "to", "tokenId"] }, + b88d4fde: { name: "safeTransferFrom", args: ["from", "to", "tokenId"] }, + f242432a: { name: "safeTransferFrom1155", args: ["from", "to", "tokenId", "amount"] }, + "2eb2c2d6": { name: "safeBatchTransferFrom", args: ["from", "to"] }, + ac9650d8: { name: "multicall", args: [] }, + "5ae401dc": { name: "multicall", args: [] }, }; + // An allowance this large is unlimited in all but name: 2^96 base units is + // 79 billion tokens at 18 decimals. Only "≥ 2^255" used to count, so + // approve(spender, 2^200) passed as an ordinary call. + const HUGE_ALLOWANCE = 1n << 96n; const UINT256_MAX = (1n << 256n) - 1n; function decodeCalldata(dataHex) { const h = normalizeData(dataHex).slice(2); @@ -218,12 +231,14 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { if (!spec || words.length < spec.args.length) return out; spec.args.forEach((a, i) => { const w = words[i]; - if (a === "amount" || a === "value" || a === "deadline") out[a] = BigInt("0x" + w); + if (a === "amount" || a === "value" || a === "deadline" || a === "tokenId") out[a] = BigInt("0x" + w); else if (a === "approved") out[a] = BigInt("0x" + w) !== 0n; else out[a] = eip55(w.slice(24)); }); const amt = out.amount ?? out.value; if (amt != null) out.unlimited = amt >= (1n << 255n) || amt === UINT256_MAX; + if (amt != null && /^(approve|increaseAllowance|increaseApproval|permit|permit2Approve)$/.test(out.name) && amt >= HUGE_ALLOWANCE) out.unlimited = true; + if (out.name === "permit2Approve" && amt != null && amt >= (1n << 159n)) out.unlimited = true; return out; } @@ -372,7 +387,23 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { } if (gas < 21000n) throw new Error("gas limit below 21000"); const feeMax = gas * maxFee; - const feeEstimate = gas * (gasPriceNow < maxFee ? gasPriceNow : maxFee); + // What a block will most likely charge. A legacy transaction pays its + // gasPrice in full; a 1559 one pays base fee + its tip, capped. Using + // the node's price here understated a dapp-chosen high tip: the + // overlay read "est. 0.0004" while the whole max was paid. + const priorityNow = hexToBig(priorityHex); + const baseNow = gasPriceNow > priorityNow ? gasPriceNow - priorityNow : gasPriceNow; + const likely = legacy ? maxFee : (baseNow + tip < maxFee ? baseNow + tip : maxFee); + const feeEstimate = gas * likely; + // A fee the site set far above what the network asks for. + let feeWarning = null; + const siteSetFee = maxFeePerGas != null || maxPriorityFeePerGas != null || gasPrice != null; + if (siteSetFee && (likely > gasPriceNow * 3n + 1_000_000_000n || (!legacy && tip > priorityNow * 3n + 2_000_000_000n))) { + feeWarning = "The site set a fee far above what the network currently charges."; + } + if (bal > 0n && feeMax * 5n > bal && feeMax > 0n) { + feeWarning = (feeWarning ? feeWarning + " " : "") + "The fee could be more than a fifth of this wallet's balance."; + } if (sendMax) { if (bal <= feeMax) throw new Error("balance does not cover the gas fee"); value = bal - feeMax; @@ -389,6 +420,7 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { recipients: [{ to: dest, value: value.toString() }], fee: feeMax.toString(), // worst case — what the balance check uses feeEstimate: feeEstimate.toString(), // what a block will most likely charge + feeWarning, feeRate: maxFee.toString(), gasLimit: gas.toString(), data: dataHex, diff --git a/bundled-addons/aegis/lib/chain-tron.js b/bundled-addons/aegis/lib/chain-tron.js index c2b36650..f49d7bac 100644 --- a/bundled-addons/aegis/lib/chain-tron.js +++ b/bundled-addons/aegis/lib/chain-tron.js @@ -62,6 +62,12 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 if (c.owner !== owner) throw new Error("node returned a transaction from another account"); if (c.to !== to) throw new Error(`node changed the recipient to ${c.to}`); if (BigInt(c.amount) !== BigInt(amount)) throw new Error(`node changed the amount to ${c.amount} sun`); + // Nothing else may ride along: a memo costs a fee on mainnet and was + // never asked for, another permission id signs for a different key set, + // and a far-off expiration keeps the transaction broadcastable for long. + if (d.memo != null) throw new Error("node added a memo to the transaction"); + if (c.permissionId) throw new Error(`node set permission id ${c.permissionId}`); + if (d.expiration && d.expiration - Date.now() > 24 * 3600e3) throw new Error("node set an expiration more than a day away"); if (draft.txID && String(draft.txID).toLowerCase() !== d.txid) throw new Error("node returned a txID that does not match the transaction"); return d.txid; } diff --git a/bundled-addons/aegis/lib/tron-decode.js b/bundled-addons/aegis/lib/tron-decode.js index 396fc3b9..aa20603a 100644 --- a/bundled-addons/aegis/lib/tron-decode.js +++ b/bundled-addons/aegis/lib/tron-decode.js @@ -121,6 +121,8 @@ module.exports = function makeTronDecode({ sha256, base58check }) { const out = { selector: sel, name: spec.name }; spec.args.forEach((a, i) => { out[a] = a === "amount" ? wordToBig(words[i]) : wordToAddr(words[i]); }); if (out.amount != null) out.unlimited = out.amount >= (1n << 255n) || out.amount === UINT256_MAX; + // An allowance of 2^96 base units or more is unlimited in all but name. + if (out.amount != null && /^(approve|increaseAllowance)$/.test(out.name) && out.amount >= (1n << 96n)) out.unlimited = true; return out; } @@ -146,8 +148,16 @@ module.exports = function makeTronDecode({ sha256, base58check }) { const data = bytesOf(vf, 4); c.data = data ? bytesToHex(data) : ""; c.call = decodeTrc20(data); + // TRC10 tokens sent along with the call (call_token_value / token_id). + // These were never read, so a call could carry the user's TRC10 + // balance to the contract while the overlay showed only the TRC20 call. + c.tokenValue = numOf(vf, 5) ?? 0n; + c.tokenId = numOf(vf, 6) ?? null; } else { c.owner = addr(bytesOf(vf, 1)); + // Everything else is shown by name only; the caller treats it as + // something Aegis could not read. + c.undecoded = true; } return c; }