// 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, };