Sign & Send wrote its refusal of a From address the selected wallet does not hold onto the password error line, which reserves one line. The sentence runs to about three, so it pushed Sign & Send down. The refusal now has its own box above the password field, its sentence fixed in index.html, holding its space from the first paint and only made visible, as #583 did for the other refusals. Drawing the screen and each press hide it again. A Chrome test writes the other wallet into the stored selection, reopens the popup onto the screen, presses Sign & Send and checks that nothing was broadcast and nothing moved. Model: opus-5-5
772 lines
31 KiB
JavaScript
772 lines
31 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,
|
||
formatUnits,
|
||
Contract,
|
||
} = require("ethers");
|
||
const {
|
||
$,
|
||
showError,
|
||
hideError,
|
||
showView,
|
||
addressTitle,
|
||
escapeHtml,
|
||
displaySymbol,
|
||
renderAddressHtml,
|
||
blockieHtml,
|
||
attachCopyHandlers,
|
||
goBack,
|
||
onViewLeave,
|
||
formatFee,
|
||
} = require("./helpers");
|
||
const { state, currentNetwork } = require("../../shared/state");
|
||
const {
|
||
networkByChainId,
|
||
nativeCurrencyByChainId,
|
||
} = require("../../shared/networks");
|
||
const { getSignerForAddress } = require("../../shared/wallet");
|
||
const { walletDefect } = require("../../shared/walletDefects");
|
||
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,
|
||
toDecimals,
|
||
transferAmountUnits,
|
||
} = require("../../shared/transferAmount");
|
||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||
// The amount, the balance line and the fee reserve 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,
|
||
maxEthAmount,
|
||
validateTransfer,
|
||
} = require("../../shared/txValidation");
|
||
const { log } = require("../../shared/log");
|
||
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;
|
||
// The fee fields a max ETH send is signed with: those of the estimate its
|
||
// amount was derived from. Null for any other send, which ethers prices from
|
||
// the node at signing time.
|
||
let maxSendFees = null;
|
||
// The From address's balances on the network this screen is drawn for, read
|
||
// from that network's node by readBalances() each time the screen is shown,
|
||
// so a screen the popup reopens onto states them as they are then, not as
|
||
// Send knew them. Null until the read returns; then `eth` and `token` are
|
||
// decimal strings, `token` null for an ETH send or a token whose scale is
|
||
// unknown, and both null if the read failed.
|
||
let balances = null;
|
||
|
||
function restore() {
|
||
const d = state.viewData;
|
||
if (d && d.pendingTx) {
|
||
show(d.pendingTx);
|
||
}
|
||
}
|
||
|
||
function confirmAddressHtml(address, ensName, title, chainId) {
|
||
const blockie = blockieHtml(address);
|
||
return (
|
||
`<div class="mb-1">${blockie}</div>` +
|
||
renderAddressHtml(address, { title, ensName, chainId })
|
||
);
|
||
}
|
||
|
||
function valueWithUsd(text, usdAmount) {
|
||
if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) {
|
||
return text + " (" + formatUsd(usdAmount) + ")";
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// The amount as this screen and the wait, success and error screens show it,
|
||
// written as the approval screen writes a site's amount: the amount being
|
||
// signed, parsed at the scale it is encoded with and written back as plain
|
||
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
|
||
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
|
||
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
|
||
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
|
||
function shownAmount(txInfo) {
|
||
let exact = txInfo.amount;
|
||
try {
|
||
if (txInfo.token === "ETH") {
|
||
exact = formatEther(parseEther(txInfo.amount));
|
||
} else {
|
||
const decimals = displayedDecimals(txInfo.tokenDecimals);
|
||
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
|
||
}
|
||
} catch {
|
||
// A token whose scale is unknown, which is never signed, or an amount
|
||
// the amount check refuses: cut as Send's amount field held it.
|
||
}
|
||
return truncateAmountNeverZero(exact);
|
||
}
|
||
|
||
// The Amount line, with its USD value, which is priced from the exact amount.
|
||
// A max ETH send's line is drawn again once its amount is re-derived from the
|
||
// fee estimate.
|
||
function renderAmount(txInfo) {
|
||
const isErc20 = txInfo.token !== "ETH";
|
||
const rawSymbol = isErc20
|
||
? txInfo.tokenSymbol || "?"
|
||
: nativeCurrencyByChainId(txInfo.chainId);
|
||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||
$("confirm-amount").textContent = valueWithUsd(
|
||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||
amountUsd,
|
||
);
|
||
}
|
||
|
||
// The balance line, with its USD value: "Loading..." until readBalances()
|
||
// returns, then the balance it read.
|
||
function renderBalance(txInfo) {
|
||
const isErc20 = txInfo.token !== "ETH";
|
||
const rawSymbol = isErc20
|
||
? txInfo.tokenSymbol || "?"
|
||
: nativeCurrencyByChainId(txInfo.chainId);
|
||
const symbol = displaySymbol(rawSymbol);
|
||
if (balances === null) {
|
||
$("confirm-balance").textContent = "Loading...";
|
||
return;
|
||
}
|
||
// A null balance is one nobody established, for a token whose scale is
|
||
// unknown or after a read that failed, and is stated as unknown, never as
|
||
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||
const bal = isErc20 ? balances.token : balances.eth;
|
||
if (bal === null) {
|
||
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
||
return;
|
||
}
|
||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
||
const balUsd = price ? parseFloat(bal) * price : null;
|
||
$("confirm-balance").textContent = valueWithUsd(
|
||
truncateAmountNeverZero(bal) + " " + symbol,
|
||
balUsd,
|
||
);
|
||
}
|
||
|
||
function show(txInfo) {
|
||
pendingTx = txInfo;
|
||
feeStatus = FEE_PENDING;
|
||
feeWei = null;
|
||
maxSendFees = null;
|
||
balances = null;
|
||
// The network this screen is drawn for, and the only one it sends on: the
|
||
// one active when Send handed the transaction over. Drawn again from the
|
||
// saved transaction, as when the popup reopens onto this screen, it keeps
|
||
// that network, so Sign & Send refuses once Settings has switched to
|
||
// another. Everything this screen shows is that network's: native
|
||
// currency, USD values, explorer links, the balance, and the node and
|
||
// explorer its checks ask. The wait, success and error screens label
|
||
// amounts by it.
|
||
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
|
||
|
||
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 || "?"
|
||
: nativeCurrencyByChainId(txInfo.chainId);
|
||
const symbol = displaySymbol(rawSymbol);
|
||
|
||
// Transaction type
|
||
if (isErc20) {
|
||
$("confirm-type").textContent =
|
||
"ERC-20 token transfer (" + symbol + ")";
|
||
} else {
|
||
$("confirm-type").textContent = "Native " + symbol + " transfer";
|
||
}
|
||
|
||
// Token contract section (ERC-20 only)
|
||
const tokenSection = $("confirm-token-section");
|
||
if (isErc20) {
|
||
$("confirm-token-contract").innerHTML = renderAddressHtml(
|
||
txInfo.token,
|
||
{ chainId: txInfo.chainId },
|
||
);
|
||
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,
|
||
txInfo.chainId,
|
||
);
|
||
|
||
// To (with blockie)
|
||
const toTitle = addressTitle(txInfo.to, state.wallets);
|
||
$("confirm-to").innerHTML = confirmAddressHtml(
|
||
txInfo.to,
|
||
txInfo.ensName,
|
||
toTitle,
|
||
txInfo.chainId,
|
||
);
|
||
$("confirm-to-ens").classList.add("hidden");
|
||
|
||
renderAmount(txInfo);
|
||
renderBalance(txInfo);
|
||
|
||
// 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);
|
||
$("confirm-gas-error").textContent =
|
||
"You do not have enough " +
|
||
nativeCurrencyByChainId(txInfo.chainId) +
|
||
" to pay the network fee for this transfer. Please add " +
|
||
nativeCurrencyByChainId(txInfo.chainId) +
|
||
" to this address and try again.";
|
||
|
||
// Both shown later, by checkRecipientHistory(). The first sentence says
|
||
// exactly what getFullWarnings() checks.
|
||
$("confirm-recipient-warning").textContent =
|
||
"WARNING: The recipient address has never sent a transaction and" +
|
||
" holds no " +
|
||
nativeCurrencyByChainId(txInfo.chainId) +
|
||
". This may indicate a fresh or unused address. Double-check the" +
|
||
" address before sending.";
|
||
$("confirm-contract-warning").textContent =
|
||
"WARNING: The recipient is a smart contract. Sending " +
|
||
nativeCurrencyByChainId(txInfo.chainId) +
|
||
" or tokens directly to a contract may result in permanent loss of" +
|
||
" funds.";
|
||
|
||
// The fee-unknown message names its cause, which is also known here.
|
||
// Without the token's scale estimateGas() cannot encode the transfer, so
|
||
// the estimate fails every time and going back cannot help; any other
|
||
// failure may clear on a retry.
|
||
$("confirm-fee-unknown-error").textContent =
|
||
isErc20 && txInfo.tokenDecimals == null
|
||
? "The network fee could not be estimated, because this wallet" +
|
||
" does not know how many decimal places this token uses, so" +
|
||
" this transaction cannot be sent."
|
||
: "The network fee could not be estimated, so this transaction" +
|
||
" cannot be checked against your balance. Please go back and" +
|
||
" try again.";
|
||
|
||
// So does the message for an amount the balance does not cover. It is
|
||
// set here, before the first paint, so the balance read returning only
|
||
// makes it visible and moves nothing, which is why it quotes no balance.
|
||
// Without the token's scale readBalances() reads no token balance.
|
||
$("confirm-balance-error").textContent =
|
||
isErc20 && toDecimals(txInfo.tokenDecimals) === 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."
|
||
: "Your " +
|
||
symbol +
|
||
" balance does not cover this amount. Please go back and send" +
|
||
" a smaller amount.";
|
||
|
||
renderValidation(txInfo);
|
||
|
||
// Reset password field and error, and any refusal of an earlier Sign &
|
||
// Send.
|
||
$("confirm-tx-password").value = "";
|
||
hideError("confirm-tx-password-error");
|
||
setVisible("confirm-network-changed-error", false);
|
||
setVisible("confirm-from-address-error", false);
|
||
setVisible("confirm-fee-bound-error", false);
|
||
|
||
// 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";
|
||
}
|
||
|
||
// The fee is estimated once the balances are read, because a max ETH
|
||
// send's amount is the ETH balance minus the estimate's reserve.
|
||
readBalances(txInfo).then(() => estimateGas(txInfo));
|
||
checkRecipientHistory(txInfo);
|
||
}
|
||
|
||
// Read the From address's balances from the node of the network this
|
||
// transaction was reviewed for: ETH, and for a token send the token, at the
|
||
// scale the screen renders the amount with. A token whose scale is unknown has
|
||
// no balance to read. Never rejects.
|
||
async function readBalances(txInfo) {
|
||
let read;
|
||
try {
|
||
const provider = reviewedNetworkProvider(txInfo);
|
||
const decimals = toDecimals(txInfo.tokenDecimals);
|
||
const readToken = txInfo.token !== "ETH" && decimals !== null;
|
||
const [ethWei, tokenUnits] = await Promise.all([
|
||
provider.getBalance(txInfo.from),
|
||
readToken
|
||
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
|
||
txInfo.from,
|
||
)
|
||
: null,
|
||
]);
|
||
read = {
|
||
eth: formatEther(ethWei),
|
||
token: readToken ? formatUnits(tokenUnits, decimals) : null,
|
||
};
|
||
} catch (e) {
|
||
log.errorf("balance read failed:", e.shortMessage || e.message);
|
||
read = { eth: null, token: null };
|
||
}
|
||
// The user may have left this transaction while the read was in flight.
|
||
if (pendingTx !== txInfo) return;
|
||
balances = read;
|
||
renderBalance(txInfo);
|
||
renderValidation(txInfo);
|
||
}
|
||
|
||
// Render the balance check for the transaction on screen. Called once during
|
||
// show(), again when the balances are read, 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";
|
||
|
||
// Null while the balances are being read and after a read that failed.
|
||
// validateTransfer() takes a missing balance as nothing to spend from, so
|
||
// sending stays blocked either way, and the only message shown for it is
|
||
// the one saying the read failed.
|
||
const ethBalance = balances ? balances.eth : null;
|
||
const tokenBalance = balances ? balances.token : null;
|
||
const balanceRead = ethBalance !== null;
|
||
|
||
const { canSend, codes } = validateTransfer({
|
||
isErc20,
|
||
amount: txInfo.amount,
|
||
tokenDecimals: txInfo.tokenDecimals,
|
||
ethBalance,
|
||
tokenBalance,
|
||
feeStatus,
|
||
feeWei,
|
||
});
|
||
|
||
// An invalid amount is known before the first paint, so its message is
|
||
// written here. Every message a later result brings has a reserved
|
||
// element, its sentence fixed in index.html or set by show(), and is only
|
||
// made visible below, so nothing moves when the result arrives.
|
||
const messages = [];
|
||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||
messages.push("Please enter a valid amount to send.");
|
||
}
|
||
|
||
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-balance-read-error", balances !== null && !balanceRead);
|
||
setVisible(
|
||
"confirm-balance-error",
|
||
balanceRead &&
|
||
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
|
||
codes.includes(CODES.INSUFFICIENT_ETH)),
|
||
);
|
||
setVisible(
|
||
"confirm-amount-fee-error",
|
||
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||
);
|
||
setVisible(
|
||
"confirm-gas-error",
|
||
balanceRead && 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";
|
||
}
|
||
|
||
// The node of the network this transaction was reviewed for, which need not be
|
||
// the active one: the popup can reopen onto this screen after Settings switched
|
||
// the network. Its RPC URL is the one Settings has for that network: the
|
||
// active network's is state.rpcUrl, any other's the one remembered for it (see
|
||
// applyChainSwitchFields()).
|
||
function reviewedNetworkProvider(txInfo) {
|
||
const net = networkByChainId(txInfo.chainId);
|
||
if (net.id === state.networkId) {
|
||
return getProvider(state.rpcUrl, net.id);
|
||
}
|
||
const remembered = state.networkEndpoints[net.id] || {};
|
||
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
|
||
}
|
||
|
||
async function estimateGas(txInfo) {
|
||
try {
|
||
const provider = reviewedNetworkProvider(txInfo);
|
||
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 is broadcast as a type-2 transaction priced at
|
||
// maxFeePerGas, which only a max ETH send pins (see below).
|
||
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,
|
||
nativeCurrencyByChainId(txInfo.chainId),
|
||
txInfo.chainId,
|
||
);
|
||
$("confirm-fee-reserve").textContent =
|
||
"up to " +
|
||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
||
" " +
|
||
nativeCurrencyByChainId(txInfo.chainId) +
|
||
" 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,
|
||
nativeCurrencyByChainId(txInfo.chainId),
|
||
txInfo.chainId,
|
||
);
|
||
setVisible("confirm-fee-reserve", false);
|
||
}
|
||
feeStatus = FEE_KNOWN;
|
||
feeWei = gasCostWei;
|
||
// A max ETH send is the balance readBalances() read minus this
|
||
// estimate's reserve, not the Send screen's, and is signed with this
|
||
// estimate's fee fields: fees fetched again at signing could exceed
|
||
// the reserve it leaves, and the node would refuse it for want of
|
||
// funds. Where the balance is unknown or no longer covers the fee, the
|
||
// amount is left as it is and the balance check below says so.
|
||
if (txInfo.max && txInfo.token === "ETH") {
|
||
const amount = maxEthAmount(balances?.eth, gasCostWei);
|
||
if (amount !== null) {
|
||
txInfo.amount = amount;
|
||
renderAmount(txInfo);
|
||
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
|
||
// or gasPrice on a network with no type-2 pricing.
|
||
if (feeData.maxFeePerGas != null) {
|
||
maxSendFees = {
|
||
gasLimit,
|
||
maxFeePerGas: feeData.maxFeePerGas,
|
||
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||
};
|
||
} else {
|
||
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
|
||
}
|
||
}
|
||
}
|
||
renderValidation(txInfo);
|
||
} catch (e) {
|
||
log.errorf("gas estimation failed:", e.shortMessage || 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 bounds against the
|
||
// gas limit and fees that were actually filled in, then sign and broadcast it.
|
||
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
|
||
// 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 gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
|
||
// over its bound, and the caller then shows the one refusal reserved for all
|
||
// three rather than sending.
|
||
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||
let request;
|
||
if (tx.token === "ETH") {
|
||
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
|
||
} 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);
|
||
}
|
||
// The chain id of the network the screen was drawn for: ethers refuses to
|
||
// populate it for a provider on any other network.
|
||
const populated = await connectedSigner.populateTransaction({
|
||
...request,
|
||
chainId: tx.chainId,
|
||
});
|
||
assertWithinCeilings(populated);
|
||
return connectedSigner.sendTransaction(populated);
|
||
}
|
||
|
||
async function checkRecipientHistory(txInfo) {
|
||
try {
|
||
const provider = reviewedNetworkProvider(txInfo);
|
||
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
||
fromAddress: txInfo.from,
|
||
chainId: txInfo.chainId,
|
||
});
|
||
// The user may have left this transaction while the checks were in
|
||
// flight; its warnings must not reach another recipient's screen.
|
||
if (pendingTx !== txInfo) return;
|
||
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 () => {
|
||
// A press shows only its own refusal, if any.
|
||
setVisible("confirm-network-changed-error", false);
|
||
setVisible("confirm-from-address-error", false);
|
||
setVisible("confirm-fee-bound-error", false);
|
||
|
||
const wallet = state.wallets[state.selectedWallet];
|
||
// Every Send button refuses a defective wallet before this screen,
|
||
// but the popup also reopens onto it from a saved view.
|
||
const defect = walletDefect(wallet);
|
||
if (defect) {
|
||
showError("confirm-tx-password-error", defect.shortMessage);
|
||
return;
|
||
}
|
||
|
||
// The screen describes the network the transaction was reviewed for,
|
||
// which Settings may have switched away from since: "Back" from
|
||
// Settings after a switch draws this screen again from the saved
|
||
// transaction, as a popup reopened onto it does.
|
||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
||
setVisible("confirm-network-changed-error", true);
|
||
return;
|
||
}
|
||
|
||
// Sign as the From address this screen shows. The selection is saved
|
||
// apart from this screen, so the popup can reopen onto it with another
|
||
// address selected.
|
||
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
|
||
if (!addr) {
|
||
setVisible("confirm-from-address-error", true);
|
||
return;
|
||
}
|
||
|
||
const password = $("confirm-tx-password").value;
|
||
if (!password) {
|
||
showError(
|
||
"confirm-tx-password-error",
|
||
"Please enter your password.",
|
||
);
|
||
return;
|
||
}
|
||
|
||
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");
|
||
|
||
// The wait, success and error screens show the amount as this screen
|
||
// does, so they are handed a copy carrying that text. pendingTx keeps
|
||
// the exact amount, which is the one signed.
|
||
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
|
||
let tx;
|
||
try {
|
||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||
const connectedSigner = signer.connect(provider);
|
||
|
||
tx = await populateVerifyAndSend(
|
||
connectedSigner,
|
||
pendingTx,
|
||
maxSendFees,
|
||
);
|
||
|
||
// 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(shownTx, tx.hash);
|
||
} catch (e) {
|
||
decryptedSecret = null;
|
||
// A gas limit or fee over its bound is refused before anything is
|
||
// broadcast, so there is no transaction that may have reached the
|
||
// network to warn about: the refusal stays on the confirmation
|
||
// screen where the user can go back, rather than routing to the
|
||
// sent/failed screen. Its sentence is fixed in index.html, so that
|
||
// its box can hold its space from the first paint; the reason in
|
||
// full, naming the bound, is logged.
|
||
if (e && e.approvalMismatch) {
|
||
log.errorf("send refused:", e.message);
|
||
setVisible("confirm-fee-bound-error", true);
|
||
return;
|
||
}
|
||
const hash = tx ? tx.hash : null;
|
||
txStatus.showError(shownTx, 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 };
|