feat: a "Max" button on the Send screen (closes #198)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s

Max fills in a token's whole 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 or
recipient 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
This commit is contained in:
2026-10-05 04:49:38 +00:00
parent cf7ca99215
commit b60eec9197
9 changed files with 823 additions and 35 deletions
+68
View File
@@ -6,6 +6,8 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
@@ -306,6 +308,72 @@ describe("feeEstimateWei", () => {
});
});
// 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.