172 lines
7.1 KiB
JavaScript
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,
|
|
};
|