// 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, Contract } = require("ethers");
const {
$,
showError,
hideError,
showView,
addressTitle,
escapeHtml,
displaySymbol,
renderAddressHtml,
attachCopyHandlers,
goBack,
onViewLeave,
formatFee,
} = 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 {
displayedDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages 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,
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";
// 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 || "?" : "ETH";
const symbol = displaySymbol(rawSymbol);
// 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(rawSymbol);
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) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
// refused at encode time for the same missing scale; what this line
// must not do is state a quantity nobody established.
const bal = txInfo.tokenBalance;
const balUsd =
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(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) =>
// 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);
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 ? displaySymbol(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(
txInfo.tokenBalance == 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."
: "Insufficient " +
symbol +
" balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" 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";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
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 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;
// 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);
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH 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);
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);
}
}
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. 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 product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx) {
let request;
if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount) };
} 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);
}
const populated = await connectedSigner.populateTransaction(request);
assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated);
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's
// own numbers, so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `${escapeHtml(message)}
`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
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 {
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");
let tx;
try {
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
// 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;
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the message stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
if (e && e.approvalMismatch) {
showSendError(e.message);
return;
}
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, populateVerifyAndSend };