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] }); }); });