fix: count the network fee in the confirm-screen balance check (closes #154)
All checks were successful
check / check (push) Successful in 25s
All checks were successful
check / check (push) Successful in 25s
This commit was merged in pull request #197.
This commit is contained in:
171
src/shared/txValidation.js
Normal file
171
src/shared/txValidation.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user