From 5cddbb4b7ae7c08149386b726a55040a558037b4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 12:19:52 +0000 Subject: [PATCH] fix: count the network fee in the confirm-screen balance check (closes #154) 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. The fee reserved is the one the node will actually require. The send pins no fee fields, so ethers broadcasts a type-2 transaction and the node validates it against value + gasLimit * maxFeePerGas; gating on gasPrice would under-reserve by roughly gasLimit * baseFee and let through exactly the broadcast failure this change exists to prevent. feeReserveWei() derives that reserve, falling back to gasPrice only where no type-2 pricing exists. The reserve is read when the screen opens and the broadcast derives its own, so a base fee that roughly doubles while the user is at the password field can still outrun it: this turns a deterministic failure on every max-value send into a rare one, not into none. The fee line shows both numbers rather than one - what the transfer is expected to cost, and below it the larger amount reserved until it confirms. Quoting only the reserve overstates the typical mainnet cost by roughly double on every send; quoting only the estimate contradicts the gate. The second line holds its space from the first paint. validateTransfer() fails closed: a feeWei that is not a non-negative bigint under FEE_KNOWN, any unrecognised feeStatus, and a negative amount all block, rather than counting as a fee of zero or as an amount that passes every comparison trivially. --- README.md | 18 +- TODO.md | 5 + docs/README.md | 5 +- src/popup/index.html | 39 +++- src/popup/views/confirmTx.js | 214 +++++++++++++++------ src/shared/txValidation.js | 171 +++++++++++++++++ tests/txValidation.test.js | 357 +++++++++++++++++++++++++++++++++++ 7 files changed, 746 insertions(+), 63 deletions(-) create mode 100644 src/shared/txValidation.js create mode 100644 tests/txValidation.test.js diff --git a/README.md b/README.md index e989ba4..fa41479 100644 --- a/README.md +++ b/README.md @@ -652,16 +652,26 @@ of it. - To: blockie + color dot + full address + etherscan link + ENS name - Amount: value + symbol (USD in parentheses) - Your balance: value + symbol (USD in parentheses) - - Estimated network fee: "Estimating..." then the ETH amount (USD in - parentheses) or "Unable to estimate", fetched async + - Network fee: "Estimating..." then two lines, or "Unable to estimate", + fetched async. The first line is what the transfer is expected to cost, + `gasLimit * gasPrice` (USD in parentheses); the second is the + `gasLimit * maxFeePerGas` reserve the node requires, which is what the + balance check gates on. The second line is omitted on a network with no + type-2 pricing, where the two are the same number, but its space is + reserved either way - Warnings: inline warnings from the local checks (scam address, self-send) plus four reserved warning boxes made visible by the async checks — recipient with no transaction history, recipient is a contract, burn address, and an Etherscan phishing/scam label - - Errors (insufficient balance) + - Errors (insufficient balance), plus three reserved error boxes — the + amount plus the fee exceeds the balance (ETH transfers), not enough ETH to + pay the fee for the transfer (ERC-20 transfers), and the fee could not be + estimated. The first two are mutually exclusive per transfer type, so only + the applicable one holds space - Password: an inline field on this screen, not a modal, with its own error line - - "Sign & Send" button (disabled if errors) + - "Sign & Send" button (disabled if errors, and while the network fee + estimate is pending or unavailable) - **Transitions**: - "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx** diff --git a/TODO.md b/TODO.md index 5c3d835..9435e5a 100644 --- a/TODO.md +++ b/TODO.md @@ -55,6 +55,11 @@ undefined identifiers, which is how - 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well into Display, next to the theme selector ([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)). +- 2026-08-11: Network fee counted in the confirmation-screen balance check for + both ETH and ERC-20 sends, reserving what the node actually charges a type-2 + transaction, with the arithmetic in a pure, unit-tested + `src/shared/txValidation.js` + ([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)). - 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of falling back to the 100,000 gwei default, and every address comparison in `src/shared/transactions.js` goes through one case-normalising helper so a diff --git a/docs/README.md b/docs/README.md index e824355..b56b2fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -269,7 +269,10 @@ The confirmation screen shows: - **From and To addresses** with identicons and Etherscan links - **Amount** with USD estimate - **Your current balance** with USD estimate -- **Estimated network fee** in ETH with USD estimate +- **Network fee** — what the transfer is expected to cost, in ETH with a USD + estimate, and below it the larger amount reserved until it confirms. The + reserve is what the network requires up front and what the balance check gates + on; the refund of the difference is why the two differ - **Warnings** if the recipient is a contract, a burn address, one of your own addresses, on the bundled scam-address list, or labelled as a phisher on Etherscan diff --git a/src/popup/index.html b/src/popup/index.html index 2c02f0d..1325c66 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -584,10 +584,18 @@
+ + +
tokenBal) { - errors.push( - "Insufficient " + - symbol + - " balance. You have " + - txInfo.tokenBalance + - " " + - symbol + - " but are trying to send " + - txInfo.amount + - " " + - symbol + - ".", - ); - } - } else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) { - errors.push( - "Insufficient balance. You have " + - txInfo.balance + - " ETH but are trying to send " + - txInfo.amount + - " ETH.", - ); - } + // The two fee messages are mutually exclusive per transaction type, and + // the type is known here, before the first paint. Drop the one that can + // never apply and reserve the space of the one that can, so the async + // estimate landing later never moves anything. + $("confirm-amount-fee-error").classList.toggle("hidden", isErc20); + $("confirm-gas-error").classList.toggle("hidden", !isErc20); - const errorsEl = $("confirm-errors"); - const sendBtn = $("btn-confirm-send"); - if (errors.length > 0) { - errorsEl.innerHTML = errors - .map((e) => `
${e}
`) - .join(""); - errorsEl.style.visibility = "visible"; - sendBtn.disabled = true; - sendBtn.classList.add("text-muted"); - } else { - errorsEl.innerHTML = ""; - errorsEl.style.visibility = "hidden"; - sendBtn.disabled = false; - sendBtn.classList.remove("text-muted"); - } + renderValidation(txInfo); // Reset password field and error $("confirm-tx-password").value = ""; @@ -205,6 +184,7 @@ function show(txInfo) { // Gas estimate — show placeholder then fetch async $("confirm-fee").style.visibility = "visible"; $("confirm-fee-amount").textContent = "Estimating..."; + setVisible("confirm-fee-reserve", false); state.viewData = { pendingTx: txInfo }; showView("confirm-tx"); attachCopyHandlers("view-confirm-tx"); @@ -224,11 +204,101 @@ function show(txInfo) { checkRecipientHistory(txInfo); } +// Render the balance check for the transaction on screen. Called once during +// show() and again when the fee estimate resolves or fails. Every element it +// touches already occupies its space, so re-running it never moves anything. +function renderValidation(txInfo) { + const isErc20 = txInfo.token !== "ETH"; + const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH"; + + const { canSend, codes } = validateTransfer({ + isErc20, + amount: txInfo.amount, + ethBalance: txInfo.balance, + tokenBalance: txInfo.tokenBalance, + feeStatus, + feeWei, + }); + + // Messages carrying the user's own numbers are built here; the fixed + // sentences live in the reserved elements in index.html. + const messages = []; + if (codes.includes(CODES.AMOUNT_INVALID)) { + messages.push("Please enter a valid amount to send."); + } + if (codes.includes(CODES.INSUFFICIENT_TOKEN)) { + messages.push( + "Insufficient " + + symbol + + " balance. You have " + + txInfo.tokenBalance + + " " + + symbol + + " but are trying to send " + + txInfo.amount + + " " + + symbol + + ".", + ); + } + if (codes.includes(CODES.INSUFFICIENT_ETH)) { + messages.push( + "Insufficient balance. You have " + + txInfo.balance + + " ETH but are trying to send " + + txInfo.amount + + " ETH.", + ); + } + + const errorsEl = $("confirm-errors"); + if (messages.length > 0) { + errorsEl.innerHTML = messages + .map((m) => `
${escapeHtml(m)}
`) + .join(""); + errorsEl.style.visibility = "visible"; + } else { + errorsEl.innerHTML = ""; + errorsEl.style.visibility = "hidden"; + } + + setVisible( + "confirm-amount-fee-error", + codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), + ); + setVisible( + "confirm-gas-error", + codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE), + ); + setVisible( + "confirm-fee-unknown-error", + codes.includes(CODES.FEE_UNAVAILABLE), + ); + + // While the estimate is in flight there is no error to show — the fee + // line already reads "Estimating..." — but sending stays blocked so a + // transaction the fee would break cannot be signed in the meantime. + const sendBtn = $("btn-confirm-send"); + sendBtn.disabled = !canSend; + sendBtn.classList.toggle("text-muted", !canSend); +} + +function setVisible(id, visible) { + $(id).style.visibility = visible ? "visible" : "hidden"; +} + +// A fee in wei as an ETH string, truncated to 6 decimal places. +function formatFeeEth(wei) { + const parts = formatEther(wei).split("."); + const dec = + parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0"; + return parts[0] + "." + dec + " ETH"; +} + async function estimateGas(txInfo) { try { const provider = getProvider(state.rpcUrl); const feeData = await provider.getFeeData(); - const gasPrice = feeData.gasPrice; let gasLimit; if (txInfo.token === "ETH") { @@ -246,21 +316,55 @@ async function estimateGas(txInfo) { }); } - const gasCostWei = gasLimit * gasPrice; - const gasCostEth = formatEther(gasCostWei); - // Format to 6 significant decimal places - const parts = gasCostEth.split("."); - const dec = - parts.length > 1 - ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" - : "0"; - const feeStr = parts[0] + "." + dec + " ETH"; + // What the node will require to be reserved, which is what the gate + // must be: the send pins no fee fields, so it is broadcast as a + // type-2 transaction priced at maxFeePerGas. + const gasCostWei = feeReserveWei(gasLimit, feeData); + if (gasCostWei === null) { + throw new Error("no usable gas price from the provider"); + } + // What the transaction is expected to cost, which is a different and + // usually much smaller number. Both are shown: quoting only the + // reserve overstates the typical cost by roughly double on mainnet, + // and quoting only the estimate contradicts the balance check. + const estimateWei = feeEstimateWei(gasLimit, feeData); + // The user may have left this transaction while the estimate was in + // flight; a stale fee must not reach the screen or the balance check. + if (pendingTx !== txInfo) return; + const ethPrice = getPrice("ETH"); - const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null; - $("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd); + const usd = (wei) => + ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null; + + if (estimateWei !== null && estimateWei < gasCostWei) { + $("confirm-fee-amount").textContent = valueWithUsd( + "~" + formatFeeEth(estimateWei), + usd(estimateWei), + ); + $("confirm-fee-reserve").textContent = + "up to " + formatFeeEth(gasCostWei) + " reserved"; + setVisible("confirm-fee-reserve", true); + } else { + // No spread to report: either there is no estimate, or the node + // quotes a gas price at or above maxFeePerGas, so the expected + // cost is not below the reserve. Show the reserve alone. + $("confirm-fee-amount").textContent = valueWithUsd( + formatFeeEth(gasCostWei), + usd(gasCostWei), + ); + setVisible("confirm-fee-reserve", false); + } + feeStatus = FEE_KNOWN; + feeWei = gasCostWei; + renderValidation(txInfo); } catch (e) { log.errorf("gas estimation failed:", e.message); + if (pendingTx !== txInfo) return; $("confirm-fee-amount").textContent = "Unable to estimate"; + setVisible("confirm-fee-reserve", false); + feeStatus = FEE_UNAVAILABLE; + feeWei = null; + renderValidation(txInfo); } } diff --git a/src/shared/txValidation.js b/src/shared/txValidation.js new file mode 100644 index 0000000..9a2e3d5 --- /dev/null +++ b/src/shared/txValidation.js @@ -0,0 +1,171 @@ +// Balance arithmetic for the transaction confirmation screen. +// +// Pure: no DOM, no network, no state. Everything is exact integer math on +// 18-decimal fixed point (wei for ETH), so it can be unit tested directly +// instead of through the confirmation view. The caller maps the returned +// codes to the reserved message elements on the screen. +// +// Human decimal strings ("1.25") are scaled to 18 decimals for comparison. +// That scale is independent of a token's own decimals: both the amount and +// the token balance arrive as human decimal strings, so comparing them at a +// common scale is exact. + +const { parseUnits } = require("ethers"); + +const SCALE_DECIMALS = 18; + +// Whether the asynchronous fee estimate has arrived yet. +const FEE_PENDING = "pending"; +const FEE_KNOWN = "known"; +const FEE_UNAVAILABLE = "unavailable"; + +const CODES = { + // The amount is not a non-negative number we can do exact arithmetic on. + AMOUNT_INVALID: "amount-invalid", + // ERC-20: the token amount exceeds the token balance. + INSUFFICIENT_TOKEN: "insufficient-token", + // ETH: the amount alone already exceeds the ETH balance. + INSUFFICIENT_ETH: "insufficient-eth", + // ETH: the amount fits, the amount plus the network fee does not. + INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee", + // ERC-20: the token balance covers the transfer, the ETH balance does + // not cover the network fee it costs. + INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee", + // The fee estimate has not arrived yet. + FEE_PENDING: "fee-pending", + // The fee estimate failed. Unknown is never treated as zero. + FEE_UNAVAILABLE: "fee-unavailable", +}; + +// The fee that must be reserved for a transaction, in wei: the amount the +// node will require, not the amount the transaction is expected to cost. +// +// A send that pins no fee fields is populated by ethers as a type-2 +// (EIP-1559) transaction, and a node validates that against +// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as +// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice` +// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and +// lets through a transaction the node then rejects with "insufficient funds +// for gas * price + value". gasPrice is the fallback only for a network that +// offers no type-2 pricing at all. +// +// Returns null when no usable price is available, which the caller must treat +// as a failed estimate rather than as a free transaction. +function feeReserveWei(gasLimit, feeData) { + if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null; + const price = feeData?.maxFeePerGas ?? feeData?.gasPrice; + if (typeof price !== "bigint" || price < 0n) return null; + return gasLimit * price; +} + +// What the transaction is expected to actually cost, in wei — not what must +// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas +// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that, +// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network +// with no type-2 pricing the two are the same number. +// +// Display only: nothing gates on this. Returns null on the same unusable +// inputs as feeReserveWei(). +function feeEstimateWei(gasLimit, feeData) { + if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null; + const price = feeData?.gasPrice ?? feeData?.maxFeePerGas; + if (typeof price !== "bigint" || price < 0n) return null; + return gasLimit * price; +} + +// Scale a human decimal string to 18-decimal fixed point. Returns null when +// the value is not a decimal number or carries more precision than the scale +// can hold, which the caller must treat as unusable rather than as zero. +function toFixedPoint(value) { + if (typeof value !== "string" && typeof value !== "number") return null; + const text = String(value).trim(); + if (text === "") return null; + try { + return parseUnits(text, SCALE_DECIMALS); + } catch (e) { + return null; + } +} + +// Validate a pending transfer against the balances that must cover it. +// +// isErc20 — token transfer rather than a native ETH transfer +// amount — human decimal string being sent, non-negative. Anything +// else, a negative value included, is an unusable amount +// rather than an amount that passes every comparison. +// ethBalance — human decimal string, the sender's ETH balance +// tokenBalance — human decimal string, the sender's token balance +// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else +// is treated as FEE_UNAVAILABLE. +// feeWei — the fee reserve in wei from feeReserveWei(), as a +// non-negative bigint, when FEE_KNOWN. Any other value makes +// the fee unavailable rather than zero. +// +// Returns { canSend, codes }. Every code blocks sending: canSend is true +// only when nothing was found. +function validateTransfer({ + isErc20 = false, + amount, + ethBalance, + tokenBalance, + feeStatus = FEE_PENDING, + feeWei = null, +} = {}) { + const codes = []; + + const amountFp = toFixedPoint(amount); + const ethFp = toFixedPoint(ethBalance) ?? 0n; + + // A negative amount parses to a valid bigint, so every comparison below + // is trivially false and the send clears the screen — then dies at encode + // time in parseEther(). Unusable, on the same footing as a malformed fee. + if (amountFp === null || amountFp < 0n) { + codes.push(CODES.AMOUNT_INVALID); + return { canSend: false, codes }; + } + + // Fail closed. Anything that is not a usable fee under a recognised + // status — a malformed feeWei, or a status this module does not know — + // is an unavailable estimate, never a fee of zero. Every such input errs + // in the direction that lets money out, so none of them is trusted. + const known = + feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n; + let status = feeStatus; + if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE; + if (status !== FEE_KNOWN && status !== FEE_PENDING) { + status = FEE_UNAVAILABLE; + } + + const feeFp = known ? feeWei : null; + + if (isErc20) { + const tokenFp = toFixedPoint(tokenBalance) ?? 0n; + if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN); + if (feeFp !== null && feeFp > ethFp) { + codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE); + } + } else if (amountFp > ethFp) { + codes.push(CODES.INSUFFICIENT_ETH); + } else if (feeFp !== null && amountFp + feeFp > ethFp) { + codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE); + } + + // An unknown fee is never assumed to be zero: sending stays blocked + // until the estimate arrives, and stays blocked if it never does. + if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING); + if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE); + + return { canSend: codes.length === 0, codes }; +} + +module.exports = { + CODES, + FEE_PENDING, + FEE_KNOWN, + FEE_UNAVAILABLE, + SCALE_DECIMALS, + feeReserveWei, + feeEstimateWei, + toFixedPoint, + validateTransfer, +}; diff --git a/tests/txValidation.test.js b/tests/txValidation.test.js new file mode 100644 index 0000000..c5d5e6c --- /dev/null +++ b/tests/txValidation.test.js @@ -0,0 +1,357 @@ +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] }); + }); +});