The stored ETH and token balances and the send-confirm screen's fee were each cut to six decimal places, so a value below 0.000001 read as zero. Balances are now stored exactly; a token declaring more than 18 decimals is cut to 18, the most the balance check reads. A token holding below 0.000001 is still left off the lists as dust, except for a token the user tracks, whose holding now reaches the Send screen and is what the send is checked against. The send and send-confirm screens' balances, reserve and insufficient-balance messages go through truncateAmountNeverZero(). The send-confirm and approval screens both render the fee through formatFee(), which prices the exact fee in USD. The balance lists still round with toFixed(4). Model: opus-5-5
1109 lines
40 KiB
JavaScript
1109 lines
40 KiB
JavaScript
const {
|
|
$,
|
|
addressTitle,
|
|
escapeHtml,
|
|
showView,
|
|
showError,
|
|
hideError,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
onViewLeave,
|
|
formatFee,
|
|
} = 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) {
|
|
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);
|
|
// Through formatFee(), as the confirmation screen's fee is, so the same
|
|
// fee reads the same on both.
|
|
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
|
|
|
|
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)");
|
|
}
|
|
|
|
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);
|
|
|
|
// 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,
|
|
};
|