Files
AutistMask/tests/txValidation.test.js
clawbot 36dd4198f1
Some checks failed
check / check (push) Has been cancelled
fix: count the network fee in the confirm-screen balance check (closes #154)
The Send button was enabled whenever the amount alone fit the balance, so
a max-value ETH send passed the confirmation screen and failed at
broadcast, after the user had committed to it.

The arithmetic moves into src/shared/txValidation.js as a pure function
over 18-decimal fixed point: native ETH now requires amount + fee <=
balance, and an ERC-20 transfer requires the ETH balance to cover the fee
on top of the token check, reported as its own error. Validation re-runs
when the async estimate resolves; Send stays disabled while the estimate
is pending and when it fails, so an unknown fee is never treated as zero.
The fee messages are static elements that already reserve their space, so
nothing moves when the estimate lands.

The fee reserved is the one the node will actually require. The send pins
no fee fields, so ethers broadcasts a type-2 transaction and the node
validates it against value + gasLimit * maxFeePerGas; gating on gasPrice
would under-reserve by roughly gasLimit * baseFee and let through exactly
the broadcast failure this change exists to prevent. feeReserveWei()
derives that reserve, falling back to gasPrice only where no type-2
pricing exists. The reserve is read when the screen opens and the
broadcast derives its own, so a base fee that roughly doubles while the
user is at the password field can still outrun it: this turns a
deterministic failure on every max-value send into a rare one, not into
none.

The fee line shows both numbers rather than one - what the transfer is
expected to cost, and below it the larger amount reserved until it
confirms. Quoting only the reserve overstates the typical mainnet cost by
roughly double on every send; quoting only the estimate contradicts the
gate. The second line holds its space from the first paint.

validateTransfer() fails closed: a feeWei that is not a non-negative
bigint under FEE_KNOWN, any unrecognised feeStatus, and a negative amount
all block, rather than counting as a fee of zero or as an amount that
passes every comparison trivially.
2026-08-11 13:36:54 +00:00

358 lines
13 KiB
JavaScript

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