// 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, showFlash, flashCopyFeedback, addressTitle, escapeHtml, renderAddressHtml, attachCopyHandlers, goBack, onViewLeave, } = require("./helpers"); const { state, currentNetwork } = require("../../shared/state"); const { getSignerForAddress } = require("../../shared/wallet"); 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 { CODES, FEE_PENDING, FEE_KNOWN, FEE_UNAVAILABLE, feeReserveWei, feeEstimateWei, validateTransfer, } = require("../../shared/txValidation"); const { log } = require("../../shared/log"); const makeBlockie = require("ethereum-blockies-base64"); 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; function restore() { const d = state.viewData; if (d && d.pendingTx) { show(d.pendingTx); } } function blockieHtml(address) { const src = makeBlockie(address); return ``; } function confirmAddressHtml(address, ensName, title) { const blockie = blockieHtml(address); return ( `
${blockie}
` + renderAddressHtml(address, { title, ensName }) ); } function valueWithUsd(text, usdAmount) { if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) { return text + " (" + formatUsd(usdAmount) + ")"; } return text; } function show(txInfo) { pendingTx = txInfo; feeStatus = FEE_PENDING; feeWei = null; const isErc20 = txInfo.token !== "ETH"; const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH"; // Transaction type if (isErc20) { $("confirm-type").textContent = "ERC-20 token transfer (" + symbol + ")"; } else { $("confirm-type").textContent = "Native ETH transfer"; } // Token contract section (ERC-20 only) const tokenSection = $("confirm-token-section"); if (isErc20) { $("confirm-token-contract").innerHTML = renderAddressHtml( txInfo.token, {}, ); 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, ); // To (with blockie) const toTitle = addressTitle(txInfo.to, state.wallets); $("confirm-to").innerHTML = confirmAddressHtml( txInfo.to, txInfo.ensName, toTitle, ); $("confirm-to-ens").classList.add("hidden"); // Amount (with inline USD) const ethPrice = getPrice("ETH"); const tokenPrice = getPrice(symbol); const amountNum = parseFloat(txInfo.amount); const price = isErc20 ? tokenPrice : ethPrice; const amountUsd = price ? amountNum * price : null; $("confirm-amount").textContent = valueWithUsd( txInfo.amount + " " + symbol, amountUsd, ); // Balance (with inline USD) if (isErc20) { const bal = txInfo.tokenBalance || "0"; const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null; $("confirm-balance").textContent = valueWithUsd( bal + " " + symbol, balUsd, ); } else { const bal = txInfo.balance || "0"; const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null; $("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd); } // 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) => `
WARNING: ${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); renderValidation(txInfo); // Reset password field and error $("confirm-tx-password").value = ""; hideError("confirm-tx-password-error"); // 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"; } estimateGas(txInfo); checkRecipientHistory(txInfo); } // Render the balance check for the transaction on screen. Called once during // show() 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"; const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH"; const { canSend, codes } = validateTransfer({ isErc20, amount: txInfo.amount, ethBalance: txInfo.balance, tokenBalance: txInfo.tokenBalance, feeStatus, feeWei, }); // Messages carrying the user's own numbers are built here; the fixed // sentences live in the reserved elements in index.html. const messages = []; if (codes.includes(CODES.AMOUNT_INVALID)) { messages.push("Please enter a valid amount to send."); } if (codes.includes(CODES.INSUFFICIENT_TOKEN)) { messages.push( "Insufficient " + symbol + " balance. You have " + txInfo.tokenBalance + " " + symbol + " but are trying to send " + txInfo.amount + " " + symbol + ".", ); } if (codes.includes(CODES.INSUFFICIENT_ETH)) { messages.push( "Insufficient balance. You have " + txInfo.balance + " ETH but are trying to send " + txInfo.amount + " ETH.", ); } 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-amount-fee-error", codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), ); setVisible( "confirm-gas-error", 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"; } // A fee in wei as an ETH string, truncated to 6 decimal places. function formatFeeEth(wei) { const parts = formatEther(wei).split("."); const dec = parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0"; return parts[0] + "." + dec + " ETH"; } async function estimateGas(txInfo) { try { const provider = getProvider(state.rpcUrl); 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); const decimals = await contract.decimals(); const amount = parseUnits(txInfo.amount, decimals); 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 pins no fee fields, so it is broadcast as a // type-2 transaction priced at maxFeePerGas. 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; const ethPrice = getPrice("ETH"); const usd = (wei) => ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null; if (estimateWei !== null && estimateWei < gasCostWei) { $("confirm-fee-amount").textContent = valueWithUsd( "~" + formatFeeEth(estimateWei), usd(estimateWei), ); $("confirm-fee-reserve").textContent = "up to " + formatFeeEth(gasCostWei) + " 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 = valueWithUsd( formatFeeEth(gasCostWei), usd(gasCostWei), ); setVisible("confirm-fee-reserve", false); } feeStatus = FEE_KNOWN; feeWei = gasCostWei; renderValidation(txInfo); } catch (e) { log.errorf("gas estimation failed:", 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); } } async function checkRecipientHistory(txInfo) { try { const provider = getProvider(state.rpcUrl); const asyncWarnings = await getFullWarnings(txInfo.to, provider, { fromAddress: txInfo.from, }); 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 () => { const password = $("confirm-tx-password").value; if (!password) { showError( "confirm-tx-password-error", "Please enter your password.", ); return; } const wallet = state.wallets[state.selectedWallet]; let decryptedSecret; hideError("confirm-tx-password-error"); try { decryptedSecret = await decryptWithPassword( wallet.encryptedSecret, password, ); } catch (e) { showError("confirm-tx-password-error", "Wrong password."); return; } $("btn-confirm-send").disabled = true; $("btn-confirm-send").classList.add("text-muted"); let tx; try { const signer = getSignerForAddress( wallet, state.selectedAddress, decryptedSecret, ); const provider = getProvider(state.rpcUrl); const connectedSigner = signer.connect(provider); if (pendingTx.token === "ETH") { tx = await connectedSigner.sendTransaction({ to: pendingTx.to, value: parseEther(pendingTx.amount), }); } else { const contract = new Contract( pendingTx.token, ERC20_ABI, connectedSigner, ); const decimals = await contract.decimals(); const amount = parseUnits(pendingTx.amount, decimals); tx = await contract.transfer(pendingTx.to, amount); } // 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(pendingTx, tx.hash); } catch (e) { decryptedSecret = null; const hash = tx ? tx.hash : null; txStatus.showError(pendingTx, 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 };