parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them. An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed. Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
515 lines
18 KiB
JavaScript
515 lines
18 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,
|
|
} = 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 {
|
|
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(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) =>
|
|
// 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 " +
|
|
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) => `<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";
|
|
}
|
|
|
|
// 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, 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;
|
|
|
|
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, 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);
|
|
|
|
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,
|
|
);
|
|
// 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 and is
|
|
// reported on the error screen. See transferAmount.js.
|
|
const amount = transferAmountUnits(
|
|
pendingTx.amount,
|
|
pendingTx.tokenDecimals,
|
|
await contract.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 };
|