Files
AutistMask/src/popup/views/approval.js
T
sneak 5aa4010f6c
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: say a contract creation has no recipient instead of a blank line (closes #250)
A transaction with no `to` showed a blank recipient line on the wait,
success and error screens and the transaction detail view: an empty
address, with a colour dot whose colour was `undefined`. The approval
screen showed "(contract creation)".

All five now say "This transaction creates a new contract. It has no
recipient." A transaction with a real `to` is unchanged. The new test
drives each screen both ways.

Model: opus-5-5
2026-10-04 07:38:00 +00:00

1112 lines
40 KiB
JavaScript

const {
$,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
showView,
showError,
hideError,
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
formatUnits,
getAddress,
getBigInt,
getBytes,
Interface,
MaxUint256,
toUtf8String,
TypedDataEncoder,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this
// view renders — the ERC-20 line, the ETH value, the max fee — and every one
// it carries forward to the wait/success/error screens goes through it.
const {
truncateAmountNeverZero: formatTxValue,
} = require("../../shared/amountDisplay");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
function approvalAddressHtml(address) {
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
}
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = formatTxValue(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20
// amount line and the swap's Amount and Min. received lines resolve it the
// same way, and refuse to format the same way when it is nowhere.
const decimalsSources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is
// still named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol);
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{
label: "Spender",
value: spender,
address: spender,
},
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amount = tokenAmountText(
rawAmount,
tokenDecimals,
tokenSymbol,
);
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
}
} catch {
// Not ERC-20 — fall through
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
if (routerResult) return routerResult;
return null;
}
function showPhishingWarning(elementId, isPhishing) {
const el = $(elementId);
if (!el) return;
// The background script performs the authoritative phishing domain check
// and passes the result via the isPhishingDomain flag.
if (isPhishing) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
// The fields of the approved transaction the value and recipient lines do not
// already carry: network, gas limit, fee per gas, the most the fee can come to,
// and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
: "Unknown network (chain id " + BigInt(approvedTx.chainId) + ")";
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
let detail =
gasLimit.toString() +
" gas at up to " +
formatUnits(feePerGas, 9) +
" gwei";
if (approvedTx.maxPriorityFeePerGas) {
detail +=
", " +
formatUnits(approvedTx.maxPriorityFeePerGas, 9) +
" gwei priority";
}
$("approve-tx-fee-detail").textContent = detail;
$("approve-tx-nonce").textContent = BigInt(approvedTx.nonce).toString();
}
function showTxApproval(details) {
showPhishingWarning(
"approve-tx-phishing-warning",
details.isPhishingDomain,
);
// The transaction the background populated. It is displayed as it stands,
// signed as it stands, and verified against as it stands — the popup fills
// nothing in, so there is no number on this screen that the background
// cannot compare with the artifact it gets back.
pendingTxParams = details.approvedTx;
const approvedTx = details.approvedTx;
const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0");
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens
pendingTxDetails = {
from: details.approvedFrom,
to: toAddr || "",
amount: formatTxValue(ethValue),
token: "ETH",
tokenSymbol: null,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
if (decoded && decoded.details) {
// The asset the status summary is counted in: an ERC-20 call's Token
// contract, or a swap's input token. Its symbol is resolved from the
// same sources as the approval screen, so a non-bundled token the
// wallet knows is not carried onto the wait and success screens as ETH.
let assetAddr = null;
for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
}
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
}
if (
(d.label === "Token" || d.label === "Token In") &&
d.isToken &&
d.address
) {
assetAddr = d.address;
}
}
if (assetAddr) {
pendingTxDetails.token = assetAddr;
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
assetAddr,
sources,
);
}
}
// Carry decoded calldata info through to success/error views
if (decoded) {
pendingTxDetails.decoded = {
name: decoded.name,
description: decoded.description,
details: decoded.details,
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null;
if (toAddr) {
let toHtml = "";
if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
}
toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
const ethValueFormatted = formatTxValue(
formatEther(approvedTx.value || "0"),
);
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
$("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice);
// Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded");
if (decoded) {
$("approve-tx-action").textContent = decoded.name;
let detailsHtml = "";
if (decoded.description) {
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
}
for (const d of decoded.details) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
}
detailsHtml += approvalAddressHtml(d.address);
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
detailsHtml += `</div>`;
}
$("approve-tx-decoded-details").innerHTML = detailsHtml;
decodedEl.classList.remove("hidden");
} else {
decodedEl.classList.add("hidden");
}
// Always show raw data when present
if (approvedTx.data && approvedTx.data !== "0x") {
$("approve-tx-data").textContent = approvedTx.data;
$("approve-tx-data-section").classList.remove("hidden");
} else {
$("approve-tx-data-section").classList.add("hidden");
}
$("approve-tx-password").value = "";
hideError("approve-tx-error");
showView("approve-tx");
attachCopyHandlers("view-approve-tx");
gateOnWalletDefect(
"approve-tx-error",
"btn-approve-tx",
details.approvedFrom,
);
}
function decodeHexMessage(hex) {
try {
const bytes = Uint8Array.from(
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
} catch {
return null;
}
}
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// Why a signature request cannot be signed, as a sentence for the error line,
// or null when it can. Only typed data is refused: the `primaryType` the page
// states has to be the type ethers will sign, or this screen would name one
// message while another is signed.
function typedDataRefusal(sp) {
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
let data;
let signed;
try {
data = JSON.parse(sp.typedData);
signed = signedPrimaryType(data.types);
} catch {
return "This typed data cannot be read, so it cannot be signed.";
}
if (!data.primaryType) {
return "This typed data does not name its primary type, so it cannot be signed.";
}
if (data.primaryType !== signed) {
return (
"This typed data names its primary type as " +
data.primaryType +
", but it would be signed as " +
signed +
", so it cannot be signed."
);
}
return null;
}
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
// it as an allowance that is never used up.
const MAX_UINT160 = (1n << 160n) - 1n;
// One field of a struct as typed data signs it: the field's declared type and
// the struct's value for it, or null when the struct's type declares no field
// of that name. ethers signs only the fields a type declares and drops every
// other key, so a key the page adds beside them is never read here.
function declaredField(types, typeName, struct, name) {
const field = (types[typeName] || []).find((f) => f.name === name);
if (!field || !struct || typeof struct !== "object") return null;
return { type: field.type, value: struct[name] };
}
// The tokens and amounts a Permit2 message grants, from its `details` or
// `permitted` field: one struct of `token` and `amount`, or a list of them,
// as the field's declared type says. When the field cannot be read the one
// grant returned has no token or amount, so the warning still lists it.
function permit2Grants(types, primaryType, message, name, max) {
const field = declaredField(types, primaryType, message, name);
if (!field) return [{ max }];
// `PermitDetails` is one grant, `PermitDetails[]` a list of them.
const itemType = field.type.replace(/\[\d*\]$/, "");
const items = itemType === field.type ? [field.value] : field.value;
if (!Array.isArray(items)) return [{ max }];
return items.map((item) => ({
token: declaredField(types, itemType, item, "token")?.value,
amount: declaredField(types, itemType, item, "amount")?.value,
max,
}));
}
// The address or number a permission field holds, or null when it holds
// none; the warning then shows `Unknown` rather than failing.
function addressOrNull(value) {
try {
return getAddress(value);
} catch {
return null;
}
}
function amountOrNull(value) {
try {
return getBigInt(value);
} catch {
return null;
}
}
// A warning for typed data that lets a spender take your tokens, naming the
// spender and each token and amount, or "" for any other typed data. These
// signatures are how most wallet drains are done, and as plain key/value
// lines they read exactly like a sign-in message. They are recognised by the
// type ethers signs, `Permit` or one of Permit2's six signature types: a
// contract checks the type's exact name, so a renamed copy of one of these
// would not be honoured. Everything named is read only from the fields that
// type declares, except a `Permit`'s token, which is the domain's
// `verifyingContract`; whatever they do not give is shown as `Unknown`.
function permitWarningHtml(types, primaryType, domain, message) {
// Each entry is a token, the amount, and the largest value its amount
// field holds, which the contract treats as unlimited.
let grants;
switch (primaryType) {
case "Permit": {
// EIP-2612 declares a `value`. DAI's older permit, signed under
// the same name, declares only `allowed`: unlimited, or nothing.
// Others, such as the permit for a Uniswap v3 position, declare
// neither, and their amount is unknown.
const value = declaredField(types, primaryType, message, "value");
const allowed = declaredField(
types,
primaryType,
message,
"allowed",
);
let amount;
if (value) amount = value.value;
else if (allowed) amount = allowed.value ? MaxUint256 : 0n;
grants = [
{ token: domain?.verifyingContract, amount, max: MaxUint256 },
];
break;
}
case "PermitSingle":
case "PermitBatch":
grants = permit2Grants(
types,
primaryType,
message,
"details",
MAX_UINT160,
);
break;
case "PermitTransferFrom":
case "PermitWitnessTransferFrom":
case "PermitBatchTransferFrom":
case "PermitBatchWitnessTransferFrom":
grants = permit2Grants(
types,
primaryType,
message,
"permitted",
MaxUint256,
);
break;
default:
return "";
}
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const spender = addressOrNull(
declaredField(types, primaryType, message, "spender")?.value,
);
let html = `<div class="mb-2 p-2 font-bold bg-red-100 text-red-800 border-2 border-red-600 rounded-md">`;
html += `<div class="mb-2">⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens listed here from your address, without asking you again.</div>`;
html += `<div class="mb-2"><div>Spender</div>`;
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
html += `</div>`;
for (const grant of grants) {
const token = addressOrNull(grant.token);
const amount = amountOrNull(grant.amount);
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
// or base units when nothing knows the token's scale.
let amountText = "Unknown";
if (amount === grant.max) {
amountText = "Unlimited";
} else if (amount !== null && token === null) {
amountText = unknownDecimalsAmount(amount);
} else if (amount !== null) {
amountText = tokenAmountText(
amount,
resolveTokenDecimals(token, sources),
tokenLabel(token),
).display;
}
html += `<div class="mb-2"><div>Token</div>`;
if (token) {
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
html += approvalAddressHtml(token);
} else {
html += `<div>Unknown</div>`;
}
html += `</div>`;
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
}
html += `</div>`;
return html;
}
// The typed data as the screen shows it. The primary type shown is the one
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
// from differing on anything that can be signed. Only typed data that cannot
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
function formatTypedDataHtml(jsonStr) {
let data;
let primaryType;
try {
data = JSON.parse(jsonStr);
primaryType = signedPrimaryType(data.types);
} catch {
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
}
let html = permitWarningHtml(
data.types,
primaryType,
data.domain,
data.message,
);
// A value that is an object is shown as JSON: String() of it says
// nothing, and throws for some objects a page can send.
const display = (val) =>
typeof val === "object" ? JSON.stringify(val) : String(val);
if (data.domain) {
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(display(val))}</div>`;
}
html += `</div>`;
}
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
}
html += `</div>`;
}
return html;
}
function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.isPhishingDomain,
);
const sp = details.signParams;
pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml(
details.approvedFrom,
);
const isTyped =
sp.method === "eth_signTypedData_v4" ||
sp.method === "eth_signTypedData";
$("approve-sign-type").textContent = isTyped
? "Typed data (EIP-712)"
: "Personal message";
if (isTyped) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else {
const decoded = decodeHexMessage(sp.message);
if (decoded !== null) {
$("approve-sign-message").textContent = decoded;
} else {
$("approve-sign-message").textContent = sp.message;
}
}
// Display danger warning for eth_sign (raw hash signing)
const warningEl = $("approve-sign-danger-warning");
if (warningEl) {
if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning;
warningEl.style.visibility = "visible";
} else {
warningEl.textContent = "";
warningEl.style.visibility = "hidden";
}
}
$("approve-sign-password").value = "";
hideError("approve-sign-error");
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) {
showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted");
return;
}
gateOnWalletDefect(
"approve-sign-error",
"btn-approve-sign",
details.approvedFrom,
);
}
// Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id;
approvalPort = runtimeApi().connect({ name: "approval:" + id });
let details = null;
try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
} catch {
details = null;
}
if (!details) {
window.close();
return;
}
if (details.type === "tx") {
showTxApproval(details);
return;
}
if (details.type === "sign") {
showSignApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
}
let approvalId = null;
// The port this approval was opened on. Closing this window disconnects it,
// and the background treats that disconnect as "closed without deciding" for a
// site connection — so the decision goes out on this same port and not as a
// one-off message. One channel is ordered: a message posted on it is delivered
// before its own disconnect, however immediately the close follows. Two
// channels were not, and the close won, reporting a user who approved as
// having refused.
let approvalPort = null;
let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval
// time. All are repopulated by show() when the popup is closed and reopened.
let pendingTxParams = null;
let pendingSignParams = null;
// The address the approval was raised for. Signing uses this rather than the
// active address, so that an address switch since the approval fails here
// instead of producing a signature from an account the screen never named.
let pendingSignFrom = null;
// Approve buttons stay disabled and muted while the popup derives the key and
// signs, which is slow enough (Argon2id) that a double click is likely.
function setTxButtonBusy(busy) {
$("btn-approve-tx").disabled = busy;
$("btn-approve-tx").classList.toggle("text-muted", busy);
}
function setSignButtonBusy(busy) {
$("btn-approve-sign").disabled = busy;
$("btn-approve-sign").classList.toggle("text-muted", busy);
}
// Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose keys
// cannot be derived. Without this the screen would take a password and fail
// after deriving it. Reject stays available; the wallet is not touched.
// Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId, address) {
const owner = findWalletFor(address);
const defect = owner ? walletDefect(owner.wallet) : null;
if (!defect) return false;
showError(errorId, defect.shortMessage);
$(buttonId).disabled = true;
$(buttonId).classList.add("text-muted");
return true;
}
// Locate the wallet and the address index owning an address. Returns null when
// no wallet holds it. Approvals look up the address they were raised for, not
// whichever address is active now: the approval named one account, and signing
// with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) {
return { wallet, addrIndex: i };
}
}
}
return null;
}
// Drop the password from the DOM when either approval screen is left. The
// approval window navigates on after a signature — approve-tx goes to the
// wait screen — and the password must not sit in the hidden view for the
// life of that window.
function clearTxPassword() {
$("approve-tx-password").value = "";
hideError("approve-tx-error");
}
function clearSignPassword() {
$("approve-sign-password").value = "";
hideError("approve-sign-error");
}
// Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
});
} catch {
// Nothing to report it to; the window closes either way.
}
}
window.close();
}
function init(_ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
$("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked;
await saveState();
});
$("btn-approve").addEventListener("click", () => {
decideSite(true);
});
$("btn-reject").addEventListener("click", () => {
decideSite(false);
});
$("btn-approve-tx").addEventListener("click", async () => {
let password = $("approve-tx-password").value;
if (!password) {
showError("approve-tx-error", "Please enter your password.");
return;
}
hideError("approve-tx-error");
setTxButtonBusy(true);
const active = findWalletFor(pendingTxParams.from);
if (!active) {
password = null;
showError(
"approve-tx-error",
"No wallet was found for the address this transaction was approved for.",
);
setTxButtonBusy(false);
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-tx-error", defect.shortMessage);
setTxButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signed transaction does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-tx-error",
"That password is incorrect. Please try again.",
);
setTxButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: true,
};
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
decryptedSecret,
);
// Sign the approved transaction exactly as it was displayed. The
// background populated it before this screen was drawn and checks
// the artifact against it field for field, so there is nothing to
// fill in here and no provider to fill it in from. The copy is
// because ethers may strip `from` off what it is handed, and the
// approval has to survive a retry intact; keeping `from` on it
// makes ethers refuse a key that is not the approved address.
payload.rawSignedTx = await signer.signTransaction({
...pendingTxParams,
});
} catch (e) {
payload.error =
e.shortMessage || e.message || "Transaction signing failed.";
} finally {
// Best-effort: clear the decrypted secret after use, with the
// same immutability caveat as the password above.
decryptedSecret = null;
}
// A send that never reaches the background is reported to the user
// the same way a background that refused it is: describeSigningFailure
// turns a null response into the generic message below.
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else {
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
$("btn-reject-tx").addEventListener("click", () => {
notify({
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: false,
});
window.close();
});
$("btn-approve-sign").addEventListener("click", async () => {
let password = $("approve-sign-password").value;
if (!password) {
showError("approve-sign-error", "Please enter your password.");
return;
}
hideError("approve-sign-error");
setSignButtonBusy(true);
const active = findWalletFor(pendingSignFrom);
if (!active) {
password = null;
showError(
"approve-sign-error",
"No wallet was found for the address this request was approved for.",
);
setSignButtonBusy(false);
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-sign-error", defect.shortMessage);
setSignButtonBusy(false);
return;
}
// Checked again where the signing starts, not only when the screen
// was drawn, and the button stays disabled: this request can never be
// signed.
const refusal = typedDataRefusal(pendingSignParams);
if (refusal) {
password = null;
showError("approve-sign-error", refusal);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-sign-error",
"That password is incorrect. Please try again.",
);
setSignButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: true,
};
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
decryptedSecret,
);
const sp = pendingSignParams;
if (sp.method === "personal_sign" || sp.method === "eth_sign") {
payload.signature = await signer.signMessage(
getBytes(sp.message),
);
} else {
// eth_signTypedData_v4 / eth_signTypedData
const typedData = JSON.parse(sp.typedData);
const { domain, types, message } = typedData;
// ethers handles EIP712Domain internally
delete types.EIP712Domain;
payload.signature = await signer.signTypedData(
domain,
types,
message,
);
}
} catch (e) {
payload.error = e.shortMessage || e.message || "Signing failed.";
} finally {
// Best-effort: clear the decrypted secret after use, with the
// same immutability caveat as the password above.
decryptedSecret = null;
}
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.signature) {
window.close();
return;
}
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
$("btn-reject-sign").addEventListener("click", () => {
notify({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,
});
window.close();
});
}
module.exports = {
init,
show,
decodeCalldata,
formatTypedDataHtml,
typedDataRefusal,
};