Aegis: dapp overlays show fees and flag risky Solana, Ethereum and Tron calls

Solana: a ComputeBudget price the site added was paid on top of the
base fee but shown as "program ComputeB...: not decoded", so a
transaction could burn the balance in priority fees behind a plain
Sign button. The maximum network fee is now a row, and Assign, durable
nonces, Approve/ApproveChecked, SetAuthority, closing a token account
to someone else and very high fees get a warning and the danger button.
signAndSend also requires one signature slot per required signer.

Ethereum: only allowances of 2^255 and up counted as unlimited; 2^96
and up now does, and Permit2 approve, increaseApproval, NFT
safeTransferFrom and multicall are decoded. The estimate shown was
gas x the node's price even when the site set a far higher tip, which
is what is actually paid; it now uses base fee + the real tip (or the
full legacy gasPrice) and warns when the site's fee is far above the
network's or a fifth of the balance. A personal_sign over 32 raw bytes
is a hash a Safe or an order book will take as approval of something
unseen, so it gets the danger button and the PIN.

Tron: TRC10 sent along with a contract call (call_token_value) was
never read, contract types Aegis does not decode were shown by name as
if harmless, a truncated TRC20 call rendered as "undefined", and the
validity window was hidden. Those are now shown, flagged or refused;
the TronGrid draft check also refuses a memo, a permission id or an
expiration more than a day away.
This commit is contained in:
Local Dev 2026-10-04 03:56:45 +02:00
parent d38da383d9
commit cc8a87c5d6
4 changed files with 189 additions and 28 deletions

View file

@ -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 (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 (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) {
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,8 +4018,14 @@ 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."
: 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}` });
rows.push({ label: "Gas", value: `${plan.gasLimit} · nonce ${plan._draft.nonce}` });
@ -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");

View file

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

View file

@ -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;
}

View file

@ -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;
}