ConfirmTx showed and checked the balance Send handed it, so a popup reopened onto a confirmation saved before a network switch stated the other network's balance and checked the amount against it. Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node of the network it is drawn for, and gives that node to the fee estimate, which starts once the read is done. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns. Model: opus-5-5
229 lines
9.7 KiB
JavaScript
229 lines
9.7 KiB
JavaScript
// The amount check and balance arithmetic for the Send and transaction
|
|
// confirmation screens.
|
|
//
|
|
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
|
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
|
// instead of through the confirmation view. The caller maps the returned
|
|
// codes to the reserved message elements on the screen.
|
|
//
|
|
// Human decimal strings ("1.25") are scaled to 18 decimals for comparison.
|
|
// That scale is independent of a token's own decimals: both the amount and
|
|
// the token balance arrive as human decimal strings, so comparing them at a
|
|
// common scale is exact.
|
|
|
|
const { parseUnits, formatEther } = require("ethers");
|
|
|
|
const SCALE_DECIMALS = 18;
|
|
|
|
// Whether the asynchronous fee estimate has arrived yet.
|
|
const FEE_PENDING = "pending";
|
|
const FEE_KNOWN = "known";
|
|
const FEE_UNAVAILABLE = "unavailable";
|
|
|
|
const CODES = {
|
|
// The amount is one isValidAmount() refuses.
|
|
AMOUNT_INVALID: "amount-invalid",
|
|
// ERC-20: the token amount exceeds the token balance.
|
|
INSUFFICIENT_TOKEN: "insufficient-token",
|
|
// ETH: the amount alone already exceeds the ETH balance.
|
|
INSUFFICIENT_ETH: "insufficient-eth",
|
|
// ETH: the amount fits, the amount plus the network fee does not.
|
|
INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee",
|
|
// ERC-20: the token balance covers the transfer, the ETH balance does
|
|
// not cover the network fee it costs.
|
|
INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee",
|
|
// The fee estimate has not arrived yet.
|
|
FEE_PENDING: "fee-pending",
|
|
// The fee estimate failed. Unknown is never treated as zero.
|
|
FEE_UNAVAILABLE: "fee-unavailable",
|
|
};
|
|
|
|
// The fee that must be reserved for a transaction, in wei: the amount the
|
|
// node will require, not the amount the transaction is expected to cost.
|
|
//
|
|
// A send that pins no fee fields is populated by ethers as a type-2
|
|
// (EIP-1559) transaction, and a node validates that against
|
|
// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as
|
|
// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice`
|
|
// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and
|
|
// lets through a transaction the node then rejects with "insufficient funds
|
|
// for gas * price + value". gasPrice is the fallback only for a network that
|
|
// offers no type-2 pricing at all.
|
|
//
|
|
// Returns null when no usable price is available, which the caller must treat
|
|
// as a failed estimate rather than as a free transaction.
|
|
function feeReserveWei(gasLimit, feeData) {
|
|
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
|
|
const price = feeData?.maxFeePerGas ?? feeData?.gasPrice;
|
|
if (typeof price !== "bigint" || price < 0n) return null;
|
|
return gasLimit * price;
|
|
}
|
|
|
|
// What the transaction is expected to actually cost, in wei — not what must
|
|
// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas
|
|
// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that,
|
|
// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network
|
|
// with no type-2 pricing the two are the same number.
|
|
//
|
|
// Display only: nothing gates on this. Returns null on the same unusable
|
|
// inputs as feeReserveWei().
|
|
function feeEstimateWei(gasLimit, feeData) {
|
|
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
|
|
const price = feeData?.gasPrice ?? feeData?.maxFeePerGas;
|
|
if (typeof price !== "bigint" || price < 0n) return null;
|
|
return gasLimit * price;
|
|
}
|
|
|
|
// Scale a human decimal string to 18-decimal fixed point. Returns null when
|
|
// the value is not a decimal number or carries more precision than the scale
|
|
// can hold, which the caller must treat as unusable rather than as zero.
|
|
function toFixedPoint(value) {
|
|
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
const text = String(value).trim();
|
|
if (text === "") return null;
|
|
try {
|
|
return parseUnits(text, SCALE_DECIMALS);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Whether `amount` can be sent: a number above zero, written as digits with
|
|
// at most one decimal point ("0.5", "12", ".5"), with nothing but zeros after
|
|
// the token's last decimal place, `tokenDecimals`, and nothing but zeros after
|
|
// the 18th, the places the arithmetic here works in, which are ETH's. ETH, and
|
|
// a token whose decimal places are unknown, pass null and are held to the 18
|
|
// (the confirmation screen refuses to send such a token anyway). Zeros past
|
|
// those places are accepted because parseUnits() drops them here and again
|
|
// when the transfer is encoded, so "1.0000000" of a 6-place token is exactly
|
|
// 1 everywhere. Refuses "1e-3", "1abc", "0" and "-1". The Send screen checks
|
|
// it before the confirmation screen opens, and validateTransfer() checks it
|
|
// again there.
|
|
function isValidAmount(amount, tokenDecimals = null) {
|
|
if (typeof amount !== "string") return false;
|
|
const places = Math.min(tokenDecimals ?? SCALE_DECIMALS, SCALE_DECIMALS);
|
|
try {
|
|
return parseUnits(amount.trim(), places) > 0n;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The most ETH a send can carry: the exact balance minus the fee reserve from
|
|
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
|
|
// exactly that reserve left behind. `ethBalance` is an exact decimal string as
|
|
// formatEther() writes it, never a rounded one. Null when the balance does not
|
|
// leave anything to send once the fee is paid, or when either input is
|
|
// unusable.
|
|
function maxEthAmount(ethBalance, feeWei) {
|
|
const balanceWei = toFixedPoint(ethBalance);
|
|
if (balanceWei === null) return null;
|
|
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
|
|
const amountWei = balanceWei - feeWei;
|
|
if (amountWei <= 0n) return null;
|
|
return formatEther(amountWei);
|
|
}
|
|
|
|
// The most of a token a send can carry: its balance cut down, never rounded
|
|
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
|
|
// declare more than 18 decimals, and its balance is stored with all of them.
|
|
function maxTokenAmount(tokenBalance) {
|
|
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
|
|
}
|
|
|
|
// Validate a pending transfer against the balances that must cover it.
|
|
//
|
|
// isErc20 — token transfer rather than a native ETH transfer
|
|
// amount — human decimal string being sent. One isValidAmount()
|
|
// refuses, a negative value included, is an unusable amount
|
|
// rather than an amount that passes every comparison.
|
|
// tokenDecimals — ERC-20: the token's decimal places, or null when they
|
|
// are unknown. Passed to isValidAmount().
|
|
// ethBalance — human decimal string, the sender's ETH balance
|
|
// tokenBalance — human decimal string, the sender's token balance
|
|
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
|
// is treated as FEE_UNAVAILABLE.
|
|
// feeWei — the fee reserve in wei from feeReserveWei(), as a
|
|
// non-negative bigint, when FEE_KNOWN. Any other value makes
|
|
// the fee unavailable rather than zero.
|
|
//
|
|
// Returns { canSend, codes }. Every code blocks sending: canSend is true
|
|
// only when nothing was found.
|
|
function validateTransfer({
|
|
isErc20 = false,
|
|
amount,
|
|
tokenDecimals = null,
|
|
ethBalance,
|
|
tokenBalance,
|
|
feeStatus = FEE_PENDING,
|
|
feeWei = null,
|
|
} = {}) {
|
|
const codes = [];
|
|
|
|
// An amount refused here never reaches the comparisons below: a negative
|
|
// one would pass every one of them, then die at encode time in
|
|
// parseEther().
|
|
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
|
|
codes.push(CODES.AMOUNT_INVALID);
|
|
return { canSend: false, codes };
|
|
}
|
|
|
|
const amountFp = toFixedPoint(amount);
|
|
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
|
|
|
// Fail closed. Anything that is not a usable fee under a recognised
|
|
// status — a malformed feeWei, or a status this module does not know —
|
|
// is an unavailable estimate, never a fee of zero. Every such input errs
|
|
// in the direction that lets money out, so none of them is trusted.
|
|
const known =
|
|
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n;
|
|
let status = feeStatus;
|
|
if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE;
|
|
if (status !== FEE_KNOWN && status !== FEE_PENDING) {
|
|
status = FEE_UNAVAILABLE;
|
|
}
|
|
|
|
const feeFp = known ? feeWei : null;
|
|
|
|
if (isErc20) {
|
|
// Only the first 18 places of the balance are read: an amount with
|
|
// more was refused above, so the places after them cannot decide
|
|
// whether the amount fits.
|
|
const tokenText =
|
|
typeof tokenBalance === "string"
|
|
? maxTokenAmount(tokenBalance)
|
|
: tokenBalance;
|
|
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
|
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
|
if (feeFp !== null && feeFp > ethFp) {
|
|
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
|
}
|
|
} else if (amountFp > ethFp) {
|
|
codes.push(CODES.INSUFFICIENT_ETH);
|
|
} else if (feeFp !== null && amountFp + feeFp > ethFp) {
|
|
codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE);
|
|
}
|
|
|
|
// An unknown fee is never assumed to be zero: sending stays blocked
|
|
// until the estimate arrives, and stays blocked if it never does.
|
|
if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING);
|
|
if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
|
|
|
|
return { canSend: codes.length === 0, codes };
|
|
}
|
|
|
|
module.exports = {
|
|
CODES,
|
|
FEE_PENDING,
|
|
FEE_KNOWN,
|
|
FEE_UNAVAILABLE,
|
|
SCALE_DECIMALS,
|
|
feeReserveWei,
|
|
feeEstimateWei,
|
|
isValidAmount,
|
|
maxEthAmount,
|
|
maxTokenAmount,
|
|
toFixedPoint,
|
|
validateTransfer,
|
|
};
|