358 lines
13 KiB
JavaScript
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] });
|
|
});
|
|
});
|