// 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, addressDotHtml, escapeHtml, } = require("./helpers"); const { state } = 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 { log } = require("../../shared/log"); const makeBlockie = require("ethereum-blockies-base64"); const txStatus = require("./txStatus"); const EXT_ICON = `` + `` + `` + `` + ``; let pendingTx = null; function restore() { const d = state.viewData; if (d && d.pendingTx) { show(d.pendingTx); } } function etherscanTokenLink(address) { return `https://etherscan.io/token/${address}`; } function etherscanAddressLink(address) { return `https://etherscan.io/address/${address}`; } function blockieHtml(address) { const src = makeBlockie(address); return ``; } function confirmAddressHtml(address, ensName, title) { const blockie = blockieHtml(address); const dot = addressDotHtml(address); const link = etherscanAddressLink(address); const extLink = `${EXT_ICON}`; let html = `
${blockie}
`; if (title) { html += `
${dot}${escapeHtml(title)}
`; } if (ensName) { html += `
${title ? "" : dot}${escapeHtml(ensName)}
`; } html += `
${title || ensName ? "" : dot}` + `${escapeHtml(address)}` + extLink + `
`; return html; } function valueWithUsd(text, usdAmount) { if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) { return text + " (" + formatUsd(usdAmount) + ")"; } return text; } function show(txInfo) { pendingTx = txInfo; 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) { const dot = addressDotHtml(txInfo.token); const link = etherscanTokenLink(txInfo.token); $("confirm-token-contract").innerHTML = `
${dot}` + `${escapeHtml(txInfo.token)}` + `${EXT_ICON}` + `
`; tokenSection.classList.remove("hidden"); // Attach click-to-copy on the contract address const copyEl = tokenSection.querySelector("[data-copy]"); if (copyEl) { copyEl.onclick = () => { navigator.clipboard.writeText(copyEl.dataset.copy); showFlash("Copied!"); flashCopyFeedback(copyEl); }; } } 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"; } // Check for errors const errors = []; if (isErc20) { const tokenBal = parseFloat(txInfo.tokenBalance || "0"); if (parseFloat(txInfo.amount) > tokenBal) { errors.push( "Insufficient " + symbol + " balance. You have " + txInfo.tokenBalance + " " + symbol + " but are trying to send " + txInfo.amount + " " + symbol + ".", ); } } else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) { errors.push( "Insufficient balance. You have " + txInfo.balance + " ETH but are trying to send " + txInfo.amount + " ETH.", ); } const errorsEl = $("confirm-errors"); const sendBtn = $("btn-confirm-send"); if (errors.length > 0) { errorsEl.innerHTML = errors .map((e) => `
${e}
`) .join(""); errorsEl.style.visibility = "visible"; sendBtn.disabled = true; sendBtn.classList.add("text-muted"); } else { errorsEl.innerHTML = ""; errorsEl.style.visibility = "hidden"; sendBtn.disabled = false; sendBtn.classList.remove("text-muted"); } // 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..."; state.viewData = { pendingTx: txInfo }; showView("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); } async function estimateGas(txInfo) { try { const provider = getProvider(state.rpcUrl); const feeData = await provider.getFeeData(); const gasPrice = feeData.gasPrice; 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, }); } const gasCostWei = gasLimit * gasPrice; const gasCostEth = formatEther(gasCostWei); // Format to 6 significant decimal places const parts = gasCostEth.split("."); const dec = parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0"; const feeStr = parts[0] + "." + dec + " ETH"; const ethPrice = getPrice("ETH"); const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null; $("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd); } catch (e) { log.errorf("gas estimation failed:", e.message); $("confirm-fee-amount").textContent = "Unable to estimate"; } } 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); } } function init(ctx) { $("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", () => { showView("send"); }); } module.exports = { init, show, restore };