All checks were successful
check / check (push) Successful in 29s
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.
186 lines
6.4 KiB
JavaScript
186 lines
6.4 KiB
JavaScript
const { parseEther } = require("ethers");
|
|
const {
|
|
CODES,
|
|
FEE_PENDING,
|
|
FEE_KNOWN,
|
|
FEE_UNAVAILABLE,
|
|
toFixedPoint,
|
|
validateTransfer,
|
|
} = require("../src/shared/txValidation");
|
|
|
|
// A plausible mainnet fee: 21000 gas at 20 gwei.
|
|
const FEE = 21000n * 20000000000n; // 0.00042 ETH
|
|
|
|
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("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("treats a missing token balance as zero", () => {
|
|
const r = validateTransfer(erc20({ tokenBalance: undefined }));
|
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
|
|
});
|
|
});
|