fix: count the network fee in the confirm-screen balance check (closes #154)
All checks were successful
check / check (push) Successful in 25s

This commit was merged in pull request #197.
This commit is contained in:
2026-08-11 15:41:37 +02:00
parent 6f6bc2e7b5
commit 158278d251
7 changed files with 746 additions and 63 deletions

357
tests/txValidation.test.js Normal file
View File

@@ -0,0 +1,357 @@
const { parseEther } = require("ethers");
const {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
// A plausible mainnet fee: 21000 gas at 20 gwei.
const FEE = 21000n * 20000000000n; // 0.00042 ETH
const GWEI = 1000000000n;
const GAS_LIMIT = 21000n;
describe("toFixedPoint", () => {
test("scales human decimals to 18 places", () => {
expect(toFixedPoint("1.5")).toBe(parseEther("1.5"));
expect(toFixedPoint("0")).toBe(0n);
});
test("rejects values it cannot represent exactly", () => {
expect(toFixedPoint("not a number")).toBe(null);
expect(toFixedPoint("")).toBe(null);
expect(toFixedPoint(null)).toBe(null);
// More precision than 18 decimals can hold.
expect(toFixedPoint("0.0000000000000000001")).toBe(null);
});
});
describe("validateTransfer, native ETH", () => {
const eth = (over) => ({
isErc20: false,
amount: "0.5",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: FEE,
...over,
});
test("allows a send comfortably within balance", () => {
const r = validateTransfer(eth());
expect(r).toEqual({ canSend: true, codes: [] });
});
test("blocks a send whose amount plus fee exceeds the balance", () => {
// The whole balance: passes an amount-only check, fails once the fee
// is counted. This is the bug this module exists to prevent.
const r = validateTransfer(eth({ amount: "1.0", ethBalance: "1.0" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("blocks a send left short by less than one fee", () => {
const balance = "1.0";
// One wei less headroom than the fee needs.
const amount = "0.99958000000000001"; // 1.0 - 0.00042 + 1e-17
const r = validateTransfer(eth({ amount, ethBalance: balance }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("allows a send that leaves exactly the fee behind", () => {
const r = validateTransfer(
eth({ amount: "0.99958", ethBalance: "1.0" }),
);
expect(r).toEqual({ canSend: true, codes: [] });
});
test("reports plain insufficient balance when the amount alone is too big", () => {
const r = validateTransfer(eth({ amount: "2.0", ethBalance: "1.0" }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
});
test("blocks while the fee estimate is still pending", () => {
const r = validateTransfer(
eth({ feeStatus: FEE_PENDING, feeWei: null }),
);
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.FEE_PENDING]);
});
test("blocks when the fee estimate failed, without assuming zero", () => {
const r = validateTransfer(
eth({
amount: "1.0",
ethBalance: "1.0",
feeStatus: FEE_UNAVAILABLE,
feeWei: null,
}),
);
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.FEE_UNAVAILABLE]);
// A zero fee would have let this exact transfer through.
expect(
validateTransfer(
eth({ amount: "1.0", ethBalance: "1.0", feeWei: 0n }),
).canSend,
).toBe(true);
});
test("still reports an over-balance amount before the estimate lands", () => {
const r = validateTransfer(
eth({
amount: "2.0",
ethBalance: "1.0",
feeStatus: FEE_PENDING,
feeWei: null,
}),
);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH, CODES.FEE_PENDING]);
});
test("rejects an amount it cannot do exact arithmetic on", () => {
const r = validateTransfer(eth({ amount: "abc" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
});
test("rejects a negative amount", () => {
// A negative amount parses to a perfectly good bigint, so neither
// balance comparison can fire: both are trivially false against it.
// Left unblocked it clears the screen and then dies at encode time.
const r = validateTransfer(
eth({ amount: "-1", ethBalance: "1.0", feeWei: 861000000000000n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(eth({ amount: "-0.000000000000000001" })),
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
});
});
describe("validateTransfer, ERC-20", () => {
const erc20 = (over) => ({
isErc20: true,
amount: "100.0",
tokenBalance: "250.0",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: FEE,
...over,
});
test("allows a transfer with tokens to spend and ETH for the fee", () => {
expect(validateTransfer(erc20())).toEqual({ canSend: true, codes: [] });
});
test("checks the token amount against the token balance", () => {
const r = validateTransfer(erc20({ amount: "250.000001" }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
});
test("does not charge the fee against the token balance", () => {
// The full token balance is sendable: the fee is paid in ETH.
expect(validateTransfer(erc20({ amount: "250.0" })).canSend).toBe(true);
});
test("blocks when the ETH balance does not cover the fee", () => {
const r = validateTransfer(erc20({ ethBalance: "0.0001" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_FOR_FEE]);
});
test("allows a fee exactly equal to the ETH balance", () => {
const r = validateTransfer(erc20({ ethBalance: "0.00042" }));
expect(r).toEqual({ canSend: true, codes: [] });
});
test("reports both shortfalls when tokens and ETH are both short", () => {
const r = validateTransfer(
erc20({ amount: "300.0", ethBalance: "0.0" }),
);
expect(r.codes).toEqual([
CODES.INSUFFICIENT_TOKEN,
CODES.INSUFFICIENT_ETH_FOR_FEE,
]);
});
test("blocks while the fee estimate is pending or failed", () => {
expect(
validateTransfer(erc20({ feeStatus: FEE_PENDING, feeWei: null }))
.codes,
).toEqual([CODES.FEE_PENDING]);
expect(
validateTransfer(
erc20({ feeStatus: FEE_UNAVAILABLE, feeWei: null }),
).codes,
).toEqual([CODES.FEE_UNAVAILABLE]);
});
test("rejects a negative token amount", () => {
const r = validateTransfer(
erc20({ amount: "-0.5", feeWei: 861000000000000n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
});
});
// The reserve a node requires, not the fee the transaction is expected to
// actually cost. An unpinned send goes out as type-2, and the node checks it
// against maxFeePerGas; reserving gasPrice lets a transaction the node will
// reject pass the gate.
describe("feeReserveWei", () => {
// baseFee 20 gwei, tip 1 gwei: eth_gasPrice reports ~21 gwei, while
// ethers populates maxFeePerGas as baseFee * 2 + tip = 41 gwei.
const type2 = {
gasPrice: 21n * GWEI,
maxFeePerGas: 41n * GWEI,
maxPriorityFeePerGas: 1n * GWEI,
};
test("reserves gasLimit * maxFeePerGas, not gasLimit * gasPrice", () => {
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(GAS_LIMIT * 41n * GWEI);
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
// The number the node would not have accepted.
expect(feeReserveWei(GAS_LIMIT, type2)).not.toBe(441000000000000n);
});
test("gates out a send the type-2 reserve cannot fund", () => {
// Exactly fundable against a gasPrice reserve (0.999559 + 0.000441 is
// the whole balance to the wei), and short against the reserve the
// node will actually require.
const send = {
isErc20: false,
amount: "0.999559",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
};
expect(
validateTransfer({
...send,
feeWei: GAS_LIMIT * type2.gasPrice,
}).canSend,
).toBe(true);
const r = validateTransfer({
...send,
feeWei: feeReserveWei(GAS_LIMIT, type2),
});
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("falls back to gasPrice on a network with no type-2 pricing", () => {
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
expect(feeReserveWei(GAS_LIMIT, legacy)).toBe(GAS_LIMIT * 21n * GWEI);
});
test("returns null when no usable price or gas limit is available", () => {
expect(feeReserveWei(GAS_LIMIT, { gasPrice: null })).toBe(null);
expect(feeReserveWei(GAS_LIMIT, {})).toBe(null);
expect(feeReserveWei(GAS_LIMIT, null)).toBe(null);
expect(feeReserveWei(21000, type2)).toBe(null);
});
});
// The display counterpart of the reserve: what the transaction is expected to
// cost. Shown alongside the reserve so the screen neither contradicts the gate
// nor quotes the user roughly double what they will pay.
describe("feeEstimateWei", () => {
const type2 = {
gasPrice: 21n * GWEI,
maxFeePerGas: 41n * GWEI,
maxPriorityFeePerGas: 1n * GWEI,
};
test("estimates gasLimit * gasPrice, below the reserve", () => {
expect(feeEstimateWei(GAS_LIMIT, type2)).toBe(441000000000000n);
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
expect(feeEstimateWei(GAS_LIMIT, type2)).toBeLessThan(
feeReserveWei(GAS_LIMIT, type2),
);
});
test("equals the reserve when the network has no type-2 pricing", () => {
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
expect(feeEstimateWei(GAS_LIMIT, legacy)).toBe(
feeReserveWei(GAS_LIMIT, legacy),
);
});
test("falls back to maxFeePerGas when there is no gasPrice", () => {
const noLegacy = { gasPrice: null, maxFeePerGas: 41n * GWEI };
expect(feeEstimateWei(GAS_LIMIT, noLegacy)).toBe(
feeReserveWei(GAS_LIMIT, noLegacy),
);
});
test("returns null on the same unusable inputs as the reserve", () => {
expect(feeEstimateWei(GAS_LIMIT, {})).toBe(null);
expect(feeEstimateWei(GAS_LIMIT, null)).toBe(null);
expect(feeEstimateWei(GAS_LIMIT, { gasPrice: -1n })).toBe(null);
expect(feeEstimateWei(21000, type2)).toBe(null);
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out.
describe("validateTransfer, unusable fee input fails closed", () => {
const fullBalanceSend = (over) => ({
isErc20: false,
amount: "1.0",
ethBalance: "1.0",
...over,
});
test("blocks a null fee claiming to be known", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: null }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks a known fee that is a number rather than a bigint", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: 420000000000000 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks an unrecognised fee status", () => {
const r = validateTransfer(fullBalanceSend({ feeStatus: "bogus" }));
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks a negative fee", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: -1n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks an ERC-20 transfer on an unusable fee too", () => {
const r = validateTransfer({
isErc20: true,
amount: "100.0",
tokenBalance: "250.0",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: null,
});
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
});