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] }); + }); +});