Files
AutistMask/src/shared/txValidation.js
T
clawbot a207ac70bd
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s
feat: a "Max" button on the Send screen (closes #198)
Max fills in a token's balance, cut down to the 18 decimal places the
confirmation screen accepts, or for ETH the exact balance minus the fee
reserve the confirmation screen's balance check gates on. An ETH fee estimate
that finishes after the Send screen was left, or its address, holding,
recipient or amount changed, fills nothing in. The confirmation screen works a
max ETH amount out again from its own fee estimate and signs it with that
estimate's fee fields, so a fee that rose before signing cannot push amount
plus fee above the balance. validateTransfer() still gates every send, the
check that ETH covers a token send's fee included. Where there is nothing to
fill in, a flash message says why.

Model: opus-5-5
2026-10-05 07:43:06 +02:00

203 lines
8.5 KiB
JavaScript

// 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 not a non-negative number we can do exact arithmetic on.
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;
}
}
// 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 the exact decimal string
// balances.js stores, 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, non-negative. Anything
// else, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// 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,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
feeWei = null,
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
// 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,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
validateTransfer,
};