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
538 lines
20 KiB
JavaScript
538 lines
20 KiB
JavaScript
// 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 };
|