Approve and window.close() left the popup on the next line, and the decision and the disconnect the close caused travelled independent channels with nothing ordering them. The disconnect handler settled a pending site approval as a rejection, so whichever landed first decided the outcome. Driven in a tab the teardown won every time: the user allowed the connection and the dApp was told they had refused. The decision now goes out on the approval port the popup already opens, which is the same port the close disconnects. One channel is ordered -- a message posted on a port is delivered before that port's own disconnect -- so the approval is settled before the teardown is even seen, and the disconnect then finds nothing pending to reject. Nothing waits, nothing is timed, and the popup closes exactly as immediately as before. windows.onRemoved no longer decides a site approval whose port is connected either. In the fallback-window shape that event races the decision on a channel of its own, which is the same defect one level over; the port disconnect says the same thing in a defined order, so it is left to say it. A window that closes before its popup ever connected has nothing else to speak for it and is still rejected there, so no dApp is left waiting on a window that is gone. Rejecting reports a rejection, and so does closing without deciding, in both shapes. AUTISTMASK_APPROVAL_RESPONSE is gone; the port name carries the approval id, so the popup no longer names one, and the sender check the message carried moved to the port. tests/backgroundApproval.test.js drives decide-then-disconnect with nothing awaited in between, in the toolbar-popup shape that production uses and in the fallback-window shape, and asserts every close-without-deciding path still rejects. tests/e2e/run.js drops the deferred-window.close() accommodation it carried for this bug, so the two site-prompt tests now drive the shipped decide-then-close in a real Chromium.
831 lines
29 KiB
JavaScript
831 lines
29 KiB
JavaScript
const {
|
|
$,
|
|
addressTitle,
|
|
escapeHtml,
|
|
showView,
|
|
showError,
|
|
hideError,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
onViewLeave,
|
|
} = require("./helpers");
|
|
const { state, saveState } = require("../../shared/state");
|
|
const { networkByChainId } = require("../../shared/networks");
|
|
const {
|
|
formatEther,
|
|
formatUnits,
|
|
getBytes,
|
|
Interface,
|
|
toUtf8String,
|
|
} = require("ethers");
|
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
|
const { ERC20_ABI } = require("../../shared/constants");
|
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
|
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 });
|
|
}
|
|
|
|
function formatTxValue(val) {
|
|
const parts = val.split(".");
|
|
if (parts.length === 1) return val + ".0000";
|
|
const dec = (parts[1] + "0000").slice(0, 4);
|
|
return parts[0] + "." + dec;
|
|
}
|
|
|
|
function tokenLabel(address) {
|
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
|
return t ? t.symbol : null;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Try ERC-20 (approve / transfer)
|
|
try {
|
|
const parsed = erc20Iface.parseTransaction({ data });
|
|
if (parsed) {
|
|
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
|
const tokenSymbol = token ? token.symbol : null;
|
|
const tokenDecimals = token ? token.decimals : 18;
|
|
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;
|
|
const amountRaw = isUnlimited
|
|
? "Unlimited"
|
|
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
|
|
const amountStr = isUnlimited
|
|
? "Unlimited"
|
|
: amountRaw + (tokenSymbol ? " " + 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: amountStr,
|
|
rawValue: amountRaw,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (parsed.name === "transfer") {
|
|
const to = parsed.args[0];
|
|
const rawAmount = parsed.args[1];
|
|
const amountRaw = formatTxValue(
|
|
formatUnits(rawAmount, tokenDecimals),
|
|
);
|
|
const amountStr =
|
|
amountRaw + (tokenSymbol ? " " + 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: amountStr,
|
|
rawValue: amountRaw,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// Not ERC-20 — fall through
|
|
}
|
|
|
|
// Try Uniswap Universal Router
|
|
const routerResult = uniswap.decode(data, toAddress);
|
|
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 token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
|
|
const ethValue = formatEther(approvedTx.value || "0");
|
|
|
|
// Build txInfo for status screens
|
|
pendingTxDetails = {
|
|
from: details.approvedFrom,
|
|
to: toAddr || "",
|
|
amount: formatTxValue(ethValue),
|
|
token: "ETH",
|
|
tokenSymbol: token ? token.symbol : 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) {
|
|
let decodedTokenAddr = null;
|
|
let decodedTokenSymbol = 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 In" && d.isToken && d.address) {
|
|
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
|
if (t) {
|
|
decodedTokenAddr = d.address;
|
|
decodedTokenSymbol = t.symbol;
|
|
}
|
|
}
|
|
}
|
|
if (token) {
|
|
pendingTxDetails.token = toAddr;
|
|
pendingTxDetails.tokenSymbol = token.symbol;
|
|
} else if (decodedTokenAddr) {
|
|
pendingTxDetails.token = decodedTokenAddr;
|
|
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
|
|
}
|
|
}
|
|
|
|
// 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, 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;
|
|
}
|
|
}
|
|
|
|
function formatTypedDataHtml(jsonStr) {
|
|
try {
|
|
const data = JSON.parse(jsonStr);
|
|
let html = "";
|
|
|
|
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(String(val))}</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
if (data.primaryType) {
|
|
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
|
html += `<div class="font-bold">${escapeHtml(data.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)) {
|
|
const display =
|
|
typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
return html;
|
|
} catch {
|
|
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
|
}
|
|
}
|
|
|
|
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");
|
|
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;
|
|
}
|
|
|
|
// 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 };
|