Send's Review accepted any amount parseFloat() read as above zero, so 1e-3, 1abc and amounts with too many decimal places reached ConfirmTx, which then refused them or failed to estimate the fee. isValidAmount() in src/shared/txValidation.js is now the one amount check, used by Send's Review and Max and by validateTransfer() for ConfirmTx: plain digits with at most one decimal point, above zero, nothing but zeros past the token's places. Zeros past those places are accepted, since the transfer encodes the amount exactly. Send now reads the token's decimal places before the ENS lookup, because the check needs them. Judgement call: ConfirmTx now also refuses zero, as Send already did. Model: opus-5-5
491 lines
18 KiB
JavaScript
491 lines
18 KiB
JavaScript
const { parseEther } = require("ethers");
|
|
const {
|
|
CODES,
|
|
FEE_PENDING,
|
|
FEE_KNOWN,
|
|
FEE_UNAVAILABLE,
|
|
feeReserveWei,
|
|
feeEstimateWei,
|
|
isValidAmount,
|
|
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);
|
|
});
|
|
});
|
|
|
|
// The one amount check the Send screen and the confirmation screen share.
|
|
// The screens themselves are driven in tests/sendAmountRule.test.js.
|
|
describe("isValidAmount", () => {
|
|
test("accepts a number above zero written as digits and a decimal point", () => {
|
|
expect(isValidAmount("0.5")).toBe(true);
|
|
expect(isValidAmount("12")).toBe(true);
|
|
expect(isValidAmount(".5")).toBe(true);
|
|
expect(isValidAmount("0.000000000000000001")).toBe(true);
|
|
});
|
|
|
|
test("refuses an exponent, trailing text, zero and a negative amount", () => {
|
|
expect(isValidAmount("1e-3")).toBe(false);
|
|
expect(isValidAmount("1abc")).toBe(false);
|
|
expect(isValidAmount("0")).toBe(false);
|
|
expect(isValidAmount("0.0")).toBe(false);
|
|
expect(isValidAmount("-1")).toBe(false);
|
|
expect(isValidAmount("")).toBe(false);
|
|
expect(isValidAmount(undefined)).toBe(false);
|
|
});
|
|
|
|
test("refuses a digit other than 0 past the token's places", () => {
|
|
expect(isValidAmount("0.000001", 6)).toBe(true);
|
|
expect(isValidAmount("0.0000001", 6)).toBe(false);
|
|
expect(isValidAmount("5", 0)).toBe(true);
|
|
expect(isValidAmount("0.5", 0)).toBe(false);
|
|
});
|
|
|
|
test("accepts zeros past the token's places, which leave the amount unchanged", () => {
|
|
expect(isValidAmount("1.0000000", 6)).toBe(true);
|
|
expect(isValidAmount("5.0", 0)).toBe(true);
|
|
expect(isValidAmount("1.0000000000000000000")).toBe(true);
|
|
expect(isValidAmount("0.0000000", 6)).toBe(false);
|
|
});
|
|
|
|
test("holds ETH, a token of unknown places, and any token to 18 places", () => {
|
|
const nineteen = "0.0000000000000000001";
|
|
expect(isValidAmount(nineteen)).toBe(false);
|
|
expect(isValidAmount(nineteen, null)).toBe(false);
|
|
expect(isValidAmount(nineteen, 24)).toBe(false);
|
|
expect(isValidAmount("0.000000000000000001", 24)).toBe(true);
|
|
});
|
|
});
|
|
|
|
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("rejects a zero amount and an exponent", () => {
|
|
expect(validateTransfer(eth({ amount: "0" }))).toEqual({
|
|
canSend: false,
|
|
codes: [CODES.AMOUNT_INVALID],
|
|
});
|
|
expect(validateTransfer(eth({ amount: "1e-3" }))).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("rejects more decimal places than the token has", () => {
|
|
const r = validateTransfer(
|
|
erc20({ amount: "0.0000001", tokenDecimals: 6 }),
|
|
);
|
|
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
|
|
expect(
|
|
validateTransfer(erc20({ amount: "0.000001", tokenDecimals: 6 })),
|
|
).toEqual({ canSend: true, codes: [] });
|
|
});
|
|
|
|
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] });
|
|
});
|
|
});
|