diff --git a/README.md b/README.md index b8df696..f147575 100644 --- a/README.md +++ b/README.md @@ -559,8 +559,11 @@ screen, including ExportPrivKey, falls back to Home. - 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 + - Maximum network fee: "Estimating..." then the ETH amount (USD in + parentheses) or "Unable to estimate", fetched async. This is the + `gasLimit * maxFeePerGas` reserve the node requires, which is also what + the balance check gates on; the transaction typically costs less once the + base fee is settled - 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 diff --git a/TODO.md b/TODO.md index d202884..80a90fc 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,11 @@ undefined identifiers, which is how # Completed Steps +- 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: Three `README.md` claims corrected against the code — blocklist attribution, token-display rule, navigation model ([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)). diff --git a/docs/README.md b/docs/README.md index 7244b8e..aca559a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -265,7 +265,8 @@ 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 +- **Maximum network fee** in ETH with USD estimate — the reserve the network + requires, which the balance check gates on; the transaction usually costs less - **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 a38e901..ad564dd 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -583,7 +583,7 @@ @@ -647,6 +647,31 @@ class="mb-2 border border-border border-dashed p-2" style="visibility: hidden; min-height: 1.25rem" > + + +
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 = ""; @@ -224,11 +202,93 @@ 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"; +} + 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,7 +306,17 @@ async function estimateGas(txInfo) { }); } - const gasCostWei = gasLimit * gasPrice; + // What the node will require to be reserved, which is what both the + // gate and the displayed figure 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"); + } + // 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 gasCostEth = formatEther(gasCostWei); // Format to 6 significant decimal places const parts = gasCostEth.split("."); @@ -258,9 +328,16 @@ async function estimateGas(txInfo) { const ethPrice = getPrice("ETH"); const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null; $("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd); + 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"; + 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..a3b9821 --- /dev/null +++ b/src/shared/txValidation.js @@ -0,0 +1,150 @@ +// 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 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; +} + +// 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 +// 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; + + if (amountFp === null) { + 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, + toFixedPoint, + validateTransfer, +}; diff --git a/tests/txValidation.test.js b/tests/txValidation.test.js new file mode 100644 index 0000000..92e3342 --- /dev/null +++ b/tests/txValidation.test.js @@ -0,0 +1,296 @@ +const { parseEther } = require("ethers"); +const { + CODES, + FEE_PENDING, + FEE_KNOWN, + FEE_UNAVAILABLE, + feeReserveWei, + 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("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]); + }); +}); + +// 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); + }); +}); + +// 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] }); + }); +});