Files
AutistMask/src/shared/txValidation.js
clawbot 158278d251
All checks were successful
check / check (push) Successful in 25s
fix: count the network fee in the confirm-screen balance check (closes #154)
2026-08-11 15:41:37 +02:00

172 lines
7.1 KiB
JavaScript

// Balance arithmetic for the transaction confirmation screen.
//
// 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 } = 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 (e) {
return null;
}
}
// 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) {
const tokenFp = toFixedPoint(tokenBalance) ?? 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,
toFixedPoint,
validateTransfer,
};