Files
AutistMask/src/popup/views/confirmTx.js
T
clawbot 504a25dead
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
The stored ETH and token balances and the send-confirm screen's fee were each
cut to six decimal places, so a value below 0.000001 read as zero. Balances are
now stored exactly, whatever decimals a token declares; the balance check reads
a token balance to its first 18 places, the most an amount can have. A token
holding below 0.000001 is still left off the lists as dust, except for a token
the user tracks. The send and send-confirm screens' balances, reserve and
insufficient-balance messages go through truncateAmountNeverZero(). The
send-confirm and approval screens both render the fee through formatFee(),
which prices the exact fee in USD. The balance lists still round with
toFixed(4).

Model: opus-5-5
2026-10-04 05:50:41 +00:00

538 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
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.
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${escapeHtml(w.message)}</div>`,
)
.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) => `<div class="text-xs">${escapeHtml(m)}</div>`)
.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 = `<div class="text-xs">${escapeHtml(message)}</div>`;
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 };