// Transaction confirmation view with inline password. // Shows transaction details, warnings, errors. On Sign & Send, // reads inline password, decrypts secret, signs and broadcasts. const { parseEther, parseUnits, formatEther, formatUnits, Contract, } = require("ethers"); const { $, showError, hideError, showView, addressTitle, escapeHtml, displaySymbol, renderAddressHtml, blockieHtml, attachCopyHandlers, goBack, onViewLeave, formatFee, reviewedNetworkProvider, } = require("./helpers"); const { state, currentNetwork } = require("../../shared/state"); const { nativeCurrencyByChainId } = require("../../shared/networks"); const { getSignerForAddress } = require("../../shared/wallet"); const { walletDefect } = require("../../shared/walletDefects"); const { decryptWithPassword } = require("../../shared/vault"); const { formatUsd, getPrice } = require("../../shared/prices"); const { getProvider } = require("../../shared/balances"); const { getLocalWarnings, getFullWarnings, } = require("../../shared/addressWarnings"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { displayedDecimals, toDecimals, transferAmountUnits, } = require("../../shared/transferAmount"); const { assertWithinCeilings } = require("../../shared/approvalVerify"); // The amount, the balance line and the fee reserve go through it, as the // approval screen's amounts do. const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { CODES, FEE_PENDING, FEE_KNOWN, FEE_UNAVAILABLE, feeReserveWei, feeEstimateWei, maxEthAmount, validateTransfer, } = require("../../shared/txValidation"); const { log } = require("../../shared/log"); const txStatus = require("./txStatus"); let pendingTx = null; // Network fee for the transaction currently on screen. Reset by show() and // filled in by estimateGas() when the estimate resolves or fails. let feeStatus = FEE_PENDING; let feeWei = null; // The fee fields a max ETH send is signed with: those of the estimate its // amount was derived from. Null for any other send, which ethers prices from // the node at signing time. let maxSendFees = null; // The From address's balances on the network this screen is drawn for, read // from that network's node by readBalances() each time the screen is shown, // so a screen the popup reopens onto states them as they are then, not as // Send knew them. Null until the read returns; then `eth` and `token` are // decimal strings, `token` null for an ETH send or a token whose scale is // unknown, and both null if the read failed. let balances = null; function restore() { const d = state.viewData; if (d && d.pendingTx) { show(d.pendingTx); } } function confirmAddressHtml(address, ensName, title, chainId) { const blockie = blockieHtml(address); return ( `
${blockie}
` + renderAddressHtml(address, { title, ensName, chainId }) ); } function valueWithUsd(text, usdAmount) { if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) { return text + " (" + formatUsd(usdAmount) + ")"; } return text; } // The amount as this screen and the wait, success and error screens show it, // written as the approval screen writes a site's amount: the amount being // signed, parsed at the scale it is encoded with and written back as plain // decimal text, then cut to four decimals with the nonzero floor. So `.5` and // `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is // unknown, which cannot be sent: its amount is cut as typed, so `.5` reads // `.5000`. txInfo.amount, which is what gets signed, is left as it is. function shownAmount(txInfo) { let exact = txInfo.amount; try { if (txInfo.token === "ETH") { exact = formatEther(parseEther(txInfo.amount)); } else { const decimals = displayedDecimals(txInfo.tokenDecimals); exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals); } } catch { // A token whose scale is unknown, which is never signed, or an amount // the amount check refuses: cut as Send's amount field held it. } return truncateAmountNeverZero(exact); } // The Amount line, with its USD value, which is priced from the exact amount. // A max ETH send's line is drawn again once its amount is re-derived from the // fee estimate. function renderAmount(txInfo) { const isErc20 = txInfo.token !== "ETH"; const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrencyByChainId(txInfo.chainId); const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const amountUsd = price ? parseFloat(txInfo.amount) * price : null; $("confirm-amount").textContent = valueWithUsd( shownAmount(txInfo) + " " + displaySymbol(rawSymbol), amountUsd, ); } // The balance line, with its USD value: "Loading..." until readBalances() // returns, then the balance it read. function renderBalance(txInfo) { const isErc20 = txInfo.token !== "ETH"; const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrencyByChainId(txInfo.chainId); const symbol = displaySymbol(rawSymbol); if (balances === null) { $("confirm-balance").textContent = "Loading..."; return; } // A null balance is one nobody established, for a token whose scale is // unknown or after a read that failed, and is stated as unknown, never as // zero (https://git.eeqj.de/sneak/AutistMask/issues/349). const bal = isErc20 ? balances.token : balances.eth; if (bal === null) { $("confirm-balance").textContent = "unknown (" + symbol + ")"; return; } const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const balUsd = price ? parseFloat(bal) * price : null; $("confirm-balance").textContent = valueWithUsd( truncateAmountNeverZero(bal) + " " + symbol, balUsd, ); } function show(txInfo) { pendingTx = txInfo; feeStatus = FEE_PENDING; feeWei = null; maxSendFees = null; balances = null; // The network this screen is drawn for, and the only one it sends on: the // one active when Send handed the transaction over. Drawn again from the // saved transaction, as when the popup reopens onto this screen, it keeps // that network, so Sign & Send refuses once Settings has switched to // another. Everything this screen shows is that network's: native // currency, USD values, explorer links, the balance, and the node and // explorer its checks ask. The wait, success and error screens label // amounts by it. if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId; const isErc20 = txInfo.token !== "ETH"; // The raw symbol is the price-table key; the capped one is what the // screen says. Truncating before the lookup would silently drop the // price of any token whose symbol is long enough to be capped. const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrencyByChainId(txInfo.chainId); const symbol = displaySymbol(rawSymbol); // Transaction type if (isErc20) { $("confirm-type").textContent = "ERC-20 token transfer (" + symbol + ")"; } else { $("confirm-type").textContent = "Native " + symbol + " transfer"; } // Token contract section (ERC-20 only) const tokenSection = $("confirm-token-section"); if (isErc20) { $("confirm-token-contract").innerHTML = renderAddressHtml( txInfo.token, { chainId: txInfo.chainId }, ); tokenSection.classList.remove("hidden"); attachCopyHandlers(tokenSection); } else { tokenSection.classList.add("hidden"); } // From (with blockie) const fromTitle = addressTitle(txInfo.from, state.wallets); $("confirm-from").innerHTML = confirmAddressHtml( txInfo.from, null, fromTitle, txInfo.chainId, ); // To (with blockie) const toTitle = addressTitle(txInfo.to, state.wallets); $("confirm-to").innerHTML = confirmAddressHtml( txInfo.to, txInfo.ensName, toTitle, txInfo.chainId, ); $("confirm-to-ens").classList.add("hidden"); renderAmount(txInfo); renderBalance(txInfo); // Check for warnings (synchronous local checks) const localWarnings = getLocalWarnings(txInfo.to, { fromAddress: txInfo.from, }); const warningsEl = $("confirm-warnings"); if (localWarnings.length > 0) { warningsEl.innerHTML = localWarnings .map( (w) => // Only the three hardcoded strings in // src/shared/addressWarnings.js reach this today, but // src/shared/etherscanLabels.js already builds a // `warning` out of scraped explorer markup, so this is // one wiring change away from carrying remote text. `
WARNING: ${escapeHtml(w.message)}
`, ) .join(""); warningsEl.style.visibility = "visible"; } else { warningsEl.innerHTML = ""; warningsEl.style.visibility = "hidden"; } // The two fee messages are mutually exclusive per transaction type, and // the type is known here, before the first paint. Drop the one that can // never apply and reserve the space of the one that can, so the async // estimate landing later never moves anything. $("confirm-amount-fee-error").classList.toggle("hidden", isErc20); $("confirm-gas-error").classList.toggle("hidden", !isErc20); $("confirm-gas-error").textContent = "You do not have enough " + nativeCurrencyByChainId(txInfo.chainId) + " to pay the network fee for this transfer. Please add " + nativeCurrencyByChainId(txInfo.chainId) + " to this address and try again."; // Both shown later, by checkRecipientHistory(). The first sentence says // exactly what getFullWarnings() checks. $("confirm-recipient-warning").textContent = "WARNING: The recipient address has never sent a transaction and" + " holds no " + nativeCurrencyByChainId(txInfo.chainId) + ". This may indicate a fresh or unused address. Double-check the" + " address before sending."; $("confirm-contract-warning").textContent = "WARNING: The recipient is a smart contract. Sending " + nativeCurrencyByChainId(txInfo.chainId) + " or tokens directly to a contract may result in permanent loss of" + " funds."; // The fee-unknown message names its cause, which is also known here. // Without the token's scale estimateGas() cannot encode the transfer, so // the estimate fails every time and going back cannot help; any other // failure may clear on a retry. $("confirm-fee-unknown-error").textContent = isErc20 && txInfo.tokenDecimals == null ? "The network fee could not be estimated, because this wallet" + " does not know how many decimal places this token uses, so" + " this transaction cannot be sent." : "The network fee could not be estimated, so this transaction" + " cannot be checked against your balance. Please go back and" + " try again."; // So does the message for an amount the balance does not cover. It is // set here, before the first paint, so the balance read returning only // makes it visible and moves nothing, which is why it quotes no balance. // Without the token's scale readBalances() reads no token balance. $("confirm-balance-error").textContent = isErc20 && toDecimals(txInfo.tokenDecimals) === null ? "This token's balance is unknown, because nothing this wallet" + " can consult reports how many decimal places it uses, so the" + " amount you are trying to send cannot be checked against it." : "Your " + symbol + " balance does not cover this amount. Please go back and send" + " a smaller amount."; renderValidation(txInfo); // Reset password field and error, and any refusal of an earlier Sign & // Send. $("confirm-tx-password").value = ""; hideError("confirm-tx-password-error"); setVisible("confirm-network-changed-error", false); setVisible("confirm-from-address-error", false); setVisible("confirm-fee-bound-error", false); // Gas estimate — show placeholder then fetch async $("confirm-fee").style.visibility = "visible"; $("confirm-fee-amount").textContent = "Estimating..."; setVisible("confirm-fee-reserve", false); state.viewData = { pendingTx: txInfo }; showView("confirm-tx"); attachCopyHandlers("view-confirm-tx"); // Reset async warnings to hidden (space always reserved, no layout shift) $("confirm-recipient-warning").style.visibility = "hidden"; $("confirm-contract-warning").style.visibility = "hidden"; $("confirm-burn-warning").style.visibility = "hidden"; $("confirm-etherscan-warning").style.visibility = "hidden"; // Show burn warning via reserved element (in addition to inline warning) if (isBurnAddress(txInfo.to)) { $("confirm-burn-warning").style.visibility = "visible"; } // The fee is estimated once the balances are read, because a max ETH // send's amount is the ETH balance minus the estimate's reserve. readBalances(txInfo).then(() => estimateGas(txInfo)); checkRecipientHistory(txInfo); } // Read the From address's balances from the node of the network this // transaction was reviewed for: ETH, and for a token send the token, at the // scale the screen renders the amount with. A token whose scale is unknown has // no balance to read. Never rejects. async function readBalances(txInfo) { let read; try { const provider = reviewedNetworkProvider(txInfo); const decimals = toDecimals(txInfo.tokenDecimals); const readToken = txInfo.token !== "ETH" && decimals !== null; const [ethWei, tokenUnits] = await Promise.all([ provider.getBalance(txInfo.from), readToken ? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf( txInfo.from, ) : null, ]); read = { eth: formatEther(ethWei), token: readToken ? formatUnits(tokenUnits, decimals) : null, }; } catch (e) { log.errorf("balance read failed:", e.shortMessage || e.message); read = { eth: null, token: null }; } // The user may have left this transaction while the read was in flight. if (pendingTx !== txInfo) return; balances = read; renderBalance(txInfo); renderValidation(txInfo); } // Render the balance check for the transaction on screen. Called once during // show(), again when the balances are read, and again when the fee estimate // resolves or fails. Every element it touches already occupies its space, so // re-running it never moves anything. function renderValidation(txInfo) { const isErc20 = txInfo.token !== "ETH"; // Null while the balances are being read and after a read that failed. // validateTransfer() takes a missing balance as nothing to spend from, so // sending stays blocked either way, and the only message shown for it is // the one saying the read failed. const ethBalance = balances ? balances.eth : null; const tokenBalance = balances ? balances.token : null; const balanceRead = ethBalance !== null; const { canSend, codes } = validateTransfer({ isErc20, amount: txInfo.amount, tokenDecimals: txInfo.tokenDecimals, ethBalance, tokenBalance, feeStatus, feeWei, }); // An invalid amount is known before the first paint, so its message is // written here. Every message a later result brings has a reserved // element, its sentence fixed in index.html or set by show(), and is only // made visible below, so nothing moves when the result arrives. const messages = []; if (codes.includes(CODES.AMOUNT_INVALID)) { messages.push("Please enter a valid amount to send."); } const errorsEl = $("confirm-errors"); if (messages.length > 0) { errorsEl.innerHTML = messages .map((m) => `
${escapeHtml(m)}
`) .join(""); errorsEl.style.visibility = "visible"; } else { errorsEl.innerHTML = ""; errorsEl.style.visibility = "hidden"; } setVisible("confirm-balance-read-error", balances !== null && !balanceRead); setVisible( "confirm-balance-error", balanceRead && (codes.includes(CODES.INSUFFICIENT_TOKEN) || codes.includes(CODES.INSUFFICIENT_ETH)), ); setVisible( "confirm-amount-fee-error", balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), ); setVisible( "confirm-gas-error", balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE), ); setVisible( "confirm-fee-unknown-error", codes.includes(CODES.FEE_UNAVAILABLE), ); // While the estimate is in flight there is no error to show — the fee // line already reads "Estimating..." — but sending stays blocked so a // transaction the fee would break cannot be signed in the meantime. const sendBtn = $("btn-confirm-send"); sendBtn.disabled = !canSend; sendBtn.classList.toggle("text-muted", !canSend); } function setVisible(id, visible) { $(id).style.visibility = visible ? "visible" : "hidden"; } async function estimateGas(txInfo) { try { const provider = reviewedNetworkProvider(txInfo); const feeData = await provider.getFeeData(); let gasLimit; if (txInfo.token === "ETH") { gasLimit = await provider.estimateGas({ from: txInfo.from, to: txInfo.to, value: parseEther(txInfo.amount), }); } else { const contract = new Contract(txInfo.token, ERC20_ABI, provider); // The scale the screen is rendering with, not the contract's own // answer: the estimate has to be for the transfer that would be // signed, and that one is encoded from what was displayed. See // transferAmount.js. A pending transaction that carries no usable // scale throws here, which reports the fee as unknown and leaves // Send blocked — an amount that cannot be checked against the // screen is never estimated for, let alone sent. const amount = parseUnits( txInfo.amount, displayedDecimals(txInfo.tokenDecimals), ); gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, { from: txInfo.from, }); } // What the node will require to be reserved, which is what the gate // must be: the send is broadcast as a type-2 transaction priced at // maxFeePerGas, which only a max ETH send pins (see below). const gasCostWei = feeReserveWei(gasLimit, feeData); if (gasCostWei === null) { throw new Error("no usable gas price from the provider"); } // What the transaction is expected to cost, which is a different and // usually much smaller number. Both are shown: quoting only the // reserve overstates the typical cost by roughly double on mainnet, // and quoting only the estimate contradicts the balance check. const estimateWei = feeEstimateWei(gasLimit, feeData); // The user may have left this transaction while the estimate was in // flight; a stale fee must not reach the screen or the balance check. if (pendingTx !== txInfo) return; // The fee line goes through formatFee(), as the approval screen's // does, so the same fee reads the same on both. if (estimateWei !== null && estimateWei < gasCostWei) { $("confirm-fee-amount").textContent = "~" + formatFee( estimateWei, nativeCurrencyByChainId(txInfo.chainId), txInfo.chainId, ); $("confirm-fee-reserve").textContent = "up to " + truncateAmountNeverZero(formatEther(gasCostWei)) + " " + nativeCurrencyByChainId(txInfo.chainId) + " reserved"; setVisible("confirm-fee-reserve", true); } else { // No spread to report: either there is no estimate, or the node // quotes a gas price at or above maxFeePerGas, so the expected // cost is not below the reserve. Show the reserve alone. $("confirm-fee-amount").textContent = formatFee( gasCostWei, nativeCurrencyByChainId(txInfo.chainId), txInfo.chainId, ); setVisible("confirm-fee-reserve", false); } feeStatus = FEE_KNOWN; feeWei = gasCostWei; // A max ETH send is the balance readBalances() read minus this // estimate's reserve, not the Send screen's, and is signed with this // estimate's fee fields: fees fetched again at signing could exceed // the reserve it leaves, and the node would refuse it for want of // funds. Where the balance is unknown or no longer covers the fee, the // amount is left as it is and the balance check below says so. if (txInfo.max && txInfo.token === "ETH") { const amount = maxEthAmount(balances?.eth, gasCostWei); if (amount !== null) { txInfo.amount = amount; renderAmount(txInfo); // Priced as feeReserveWei() priced the reserve: maxFeePerGas, // or gasPrice on a network with no type-2 pricing. if (feeData.maxFeePerGas != null) { maxSendFees = { gasLimit, maxFeePerGas: feeData.maxFeePerGas, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas, }; } else { maxSendFees = { gasLimit, gasPrice: feeData.gasPrice }; } } } renderValidation(txInfo); } catch (e) { log.errorf("gas estimation failed:", e.shortMessage || e.message); if (pendingTx !== txInfo) return; $("confirm-fee-amount").textContent = "Unable to estimate"; setVisible("confirm-fee-reserve", false); feeStatus = FEE_UNAVAILABLE; feeWei = null; renderValidation(txInfo); } } // Populate the transaction this send describes, enforce the bounds against the // gas limit and fees that were actually filled in, then sign and broadcast it. // Apart from a max ETH send, which passes its estimate's fee fields as `fees`, // the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit // from what the configured RPC node answers, with nothing otherwise bounding // what a hostile node can set — the dApp path's ceilings never reached this // one. Populating before the check is what makes assertWithinCeilings() see // the same numbers that would be signed; it throws an ApprovalMismatchError // when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is // over its bound, and the caller then shows the one refusal reserved for all // three rather than sending. async function populateVerifyAndSend(connectedSigner, tx, fees = null) { let request; if (tx.token === "ETH") { request = { to: tx.to, value: parseEther(tx.amount), ...fees }; } else { const contract = new Contract(tx.token, ERC20_ABI, connectedSigner); // The contract's decimals() is read to be COMPARED with the scale the // screen rendered this amount at, not to encode with: encoding from it // signs whatever the contract answers now, which is not what the user // read. A disagreement throws. See transferAmount.js. const amount = transferAmountUnits( tx.amount, tx.tokenDecimals, await contract.decimals(), ); request = await contract.transfer.populateTransaction(tx.to, amount); } // The chain id of the network the screen was drawn for: ethers refuses to // populate it for a provider on any other network. const populated = await connectedSigner.populateTransaction({ ...request, chainId: tx.chainId, }); assertWithinCeilings(populated); return connectedSigner.sendTransaction(populated); } async function checkRecipientHistory(txInfo) { try { const provider = reviewedNetworkProvider(txInfo); const asyncWarnings = await getFullWarnings(txInfo.to, provider, { fromAddress: txInfo.from, chainId: txInfo.chainId, }); // The user may have left this transaction while the checks were in // flight; its warnings must not reach another recipient's screen. if (pendingTx !== txInfo) return; for (const w of asyncWarnings) { if (w.type === "contract") { $("confirm-contract-warning").style.visibility = "visible"; } if (w.type === "new-address") { $("confirm-recipient-warning").style.visibility = "visible"; } if (w.type === "etherscan-phishing") { $("confirm-etherscan-warning").style.visibility = "visible"; } } } catch (e) { log.errorf("recipient history check failed:", e.message); } } // Drop the password from the DOM. Registered as the view-leave handler so // it does not sit in the hidden view once the screen navigates on — to the // wait screen after a send, or anywhere else the user goes. function clearPassword() { $("confirm-tx-password").value = ""; hideError("confirm-tx-password-error"); } function init(_ctx) { onViewLeave("confirm-tx", clearPassword); $("btn-confirm-send").addEventListener("click", async () => { // A press shows only its own refusal, if any. setVisible("confirm-network-changed-error", false); setVisible("confirm-from-address-error", false); setVisible("confirm-fee-bound-error", false); const wallet = state.wallets[state.selectedWallet]; // Every Send button refuses a defective wallet before this screen, // but the popup also reopens onto it from a saved view. const defect = walletDefect(wallet); if (defect) { showError("confirm-tx-password-error", defect.shortMessage); return; } // The screen describes the network the transaction was reviewed for, // which Settings may have switched away from since: "Back" from // Settings after a switch draws this screen again from the saved // transaction, as a popup reopened onto it does. if (currentNetwork().chainId !== pendingTx.chainId) { setVisible("confirm-network-changed-error", true); return; } // Sign as the From address this screen shows. The selection is saved // apart from this screen, so the popup can reopen onto it with another // address selected. const addr = wallet.addresses.find((a) => a.address === pendingTx.from); if (!addr) { setVisible("confirm-from-address-error", true); return; } const password = $("confirm-tx-password").value; if (!password) { showError( "confirm-tx-password-error", "Please enter your password.", ); return; } let decryptedSecret; hideError("confirm-tx-password-error"); try { decryptedSecret = await decryptWithPassword( wallet.encryptedSecret, password, ); } catch { showError( "confirm-tx-password-error", "That password is incorrect. Please try again.", ); return; } $("btn-confirm-send").disabled = true; $("btn-confirm-send").classList.add("text-muted"); // The wait, success and error screens show the amount as this screen // does, so they are handed a copy carrying that text. pendingTx keeps // the exact amount, which is the one signed. const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) }; let tx; try { const signer = getSignerForAddress(wallet, addr, decryptedSecret); const provider = getProvider(state.rpcUrl, state.networkId); const connectedSigner = signer.connect(provider); tx = await populateVerifyAndSend( connectedSigner, pendingTx, maxSendFees, ); // Best-effort: clear decrypted secret after use. // Note: JS strings are immutable; this nulls the reference but // the original string may persist in memory until GC. decryptedSecret = null; txStatus.showWait(shownTx, tx.hash); } catch (e) { decryptedSecret = null; // A gas limit or fee over its bound is refused before anything is // broadcast, so there is no transaction that may have reached the // network to warn about: the refusal stays on the confirmation // screen where the user can go back, rather than routing to the // sent/failed screen. Its sentence is fixed in index.html, so that // its box can hold its space from the first paint; the reason in // full, naming the bound, is logged. if (e && e.approvalMismatch) { log.errorf("send refused:", e.message); setVisible("confirm-fee-bound-error", true); return; } const hash = tx ? tx.hash : null; txStatus.showError(shownTx, hash, e.shortMessage || e.message); } finally { $("btn-confirm-send").disabled = false; $("btn-confirm-send").classList.remove("text-muted"); } }); $("btn-confirm-back").addEventListener("click", () => { goBack(); }); } module.exports = { init, show, restore, populateVerifyAndSend };