Max fills in a token's balance, cut down to the 18 decimal places the confirmation screen accepts, or for ETH the exact balance minus the fee reserve the confirmation screen's balance check gates on. An ETH fee estimate that finishes after the Send screen was left, or its address, holding, recipient or amount changed, fills nothing in. The confirmation screen works a max ETH amount out again from its own fee estimate and signs it with that estimate's fee fields, so a fee that rose before signing cannot push amount plus fee above the balance. validateTransfer() still gates every send, the check that ETH covers a token send's fee included. Where there is nothing to fill in, a flash message says why. Model: opus-5-5
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
const { parseEther } = require("ethers");
|
|
const {
|
|
CODES,
|
|
FEE_PENDING,
|
|
FEE_KNOWN,
|
|
FEE_UNAVAILABLE,
|
|
feeReserveWei,
|
|
feeEstimateWei,
|
|
maxEthAmount,
|
|
maxTokenAmount,
|
|
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);
|
|
});
|
|
});
|
|
|
|
// The amount the Send screen's Max fills in for ETH: the exact balance, as
|
|
// balances.js stores it, minus the fee reserve. Never the four-decimal balance
|
|
// the Send screen shows.
|
|
describe("maxEthAmount", () => {
|
|
// The Send screen shows this balance as 1.2345.
|
|
const BALANCE = "1.234567890123456789";
|
|
|
|
test("is the exact balance minus the fee, to the wei", () => {
|
|
expect(maxEthAmount(BALANCE, FEE)).toBe("1.234147890123456789");
|
|
expect(
|
|
parseEther(BALANCE) - parseEther(maxEthAmount(BALANCE, FEE)),
|
|
).toBe(FEE);
|
|
});
|
|
|
|
test("passes validateTransfer with exactly the fee left behind", () => {
|
|
const r = validateTransfer({
|
|
isErc20: false,
|
|
amount: maxEthAmount(BALANCE, FEE),
|
|
ethBalance: BALANCE,
|
|
feeStatus: FEE_KNOWN,
|
|
feeWei: FEE,
|
|
});
|
|
expect(r).toEqual({ canSend: true, codes: [] });
|
|
});
|
|
|
|
test("is one wei when the balance is one wei more than the fee", () => {
|
|
expect(maxEthAmount("0.000420000000000001", FEE)).toBe(
|
|
"0.000000000000000001",
|
|
);
|
|
});
|
|
|
|
test("is null when the balance does not cover the fee", () => {
|
|
expect(maxEthAmount("0.0001", FEE)).toBe(null);
|
|
expect(maxEthAmount("0.0", FEE)).toBe(null);
|
|
});
|
|
|
|
test("is null when the balance covers the fee and nothing more", () => {
|
|
expect(maxEthAmount("0.00042", FEE)).toBe(null);
|
|
});
|
|
|
|
test("is null on a balance or fee it cannot do exact arithmetic on", () => {
|
|
expect(maxEthAmount(undefined, FEE)).toBe(null);
|
|
expect(maxEthAmount("not a number", FEE)).toBe(null);
|
|
expect(maxEthAmount(BALANCE, null)).toBe(null);
|
|
expect(maxEthAmount(BALANCE, -1n)).toBe(null);
|
|
expect(maxEthAmount(BALANCE, 420000000000000)).toBe(null);
|
|
});
|
|
});
|
|
|
|
// The amount the Send screen's Max fills in for a token.
|
|
describe("maxTokenAmount", () => {
|
|
test("cuts a balance with more than 18 places down, never up", () => {
|
|
const amount = maxTokenAmount("1234.567890123456789012999999");
|
|
expect(amount).toBe("1234.567890123456789012");
|
|
expect(toFixedPoint(amount)).not.toBe(null);
|
|
});
|
|
|
|
test("leaves a balance with 18 places or fewer as it is", () => {
|
|
expect(maxTokenAmount("0.123456789012345678")).toBe(
|
|
"0.123456789012345678",
|
|
);
|
|
expect(maxTokenAmount("1.5")).toBe("1.5");
|
|
expect(maxTokenAmount("100")).toBe("100");
|
|
});
|
|
});
|
|
|
|
// 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] });
|
|
});
|
|
});
|