From 72f1eb7217aeb43cf65d8cd1007c43f20cb29852 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 01:22:45 +0000 Subject: [PATCH] fix: show balances and fees below 0.000001 ETH as nonzero on the send screens (closes #343) The stored ETH balance and the send-confirm screen's fee were each cut to six decimal places, so a value below 0.000001 read as 0.0. The ETH balance is now stored exactly, and the send and send-confirm screens' balances, reserve and insufficient-balance messages go through truncateAmountNeverZero(). Both the send-confirm and approval screens render the fee through formatFee(), which prices the exact fee in USD, so the same fee reads the same on both. Token balances keep their six-decimal value: it also leaves dust off the balance list, and keeps the string within the 18 decimals the balance check reads. Model: opus-5-5 --- README.md | 20 ++- TODO.md | 14 ++ src/popup/views/approval.js | 14 +- src/popup/views/confirmTx.js | 46 +++--- src/popup/views/helpers.js | 16 ++ src/popup/views/send.js | 10 +- src/shared/balances.js | 19 +-- tests/e2e/run.js | 33 ++-- tests/sendDisplayFloor.test.js | 284 +++++++++++++++++++++++++++++++++ tests/unknownScaleSend.test.js | 6 +- 10 files changed, 392 insertions(+), 70 deletions(-) create mode 100644 tests/sendDisplayFloor.test.js diff --git a/README.md b/README.md index c7cf35c..9cb1f7e 100644 --- a/README.md +++ b/README.md @@ -892,12 +892,20 @@ shows is a V4 exact-in `amountIn` of zero. The rule and its exception live in `src/shared/amountDisplay.js` as `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and confirmation screens display goes through the floored one — the ERC-20 amount, -the ETH value and max fee (`src/popup/views/approval.js`), and the swap's -`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and -balance lists (`src/shared/transactions.js`) use the unfloored one: the -transaction detail view is the authoritative record and already shows exact -precision. The 4-decimal rule is unchanged everywhere else, including for -amounts at or above the floor on the approval screens. +the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount` +and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's +`Current balance` (`src/popup/views/send.js`), and the balance and network fee +on the confirmation screen for the wallet's own send +(`src/popup/views/confirmTx.js`). Both screens render a network fee through +`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD +rather than its truncated figure, so the same fee reads the same on both, USD +value included. The ETH balance is stored exactly, so a balance below the floor +reaches these screens as it is. A token balance is stored to six decimal places, +and a holding below 0.000001 is not listed at all. The history and balance lists +(`src/shared/transactions.js`) use the unfloored one: the transaction detail +view is the authoritative record and already shows exact precision. The +4-decimal rule is unchanged everywhere else, including for amounts at or above +the floor on the approval screens. The floor applies only where the token's scale is known. Where it is not, the approval screen states base units instead of a quantity — see Unknown token diff --git a/TODO.md b/TODO.md index f06c9c2..0534ce8 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,20 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: The Send and confirmation screens no longer show an ETH balance or + a network fee below 0.000001 as `0.0` + ([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored ETH + balance (`src/shared/balances.js`) and the confirmation screen's fee were each + cut to six decimal places by a rule of their own. The ETH balance is now + stored exactly, and the Send screen's `Current balance`, and the confirmation + screen's balance, fee, reserve and insufficient-balance messages, go through + `truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the + approval screen already used. The confirmation and approval screens both + render the fee through `formatFee()` in `src/popup/views/helpers.js`, which + prices the exact fee in USD, so the same fee reads the same on both, USD value + included. Token balances are still stored to six decimal places: that cut is + also what leaves a holding below 0.000001 off the balance list, and keeps the + stored string within the 18 decimals the balance check reads. - 2026-10-04: Removing an address or deleting a wallet ends every site connection approved without "Remember" for the addresses removed ([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 2428dc5..b3738c5 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -8,6 +8,7 @@ const { renderAddressHtml, attachCopyHandlers, onViewLeave, + formatFee, } = require("./helpers"); const { state, saveState } = require("../../shared/state"); const { networkByChainId } = require("../../shared/networks"); @@ -207,7 +208,7 @@ function showPhishingWarning(elementId, isPhishing) { // and the nonce. The background compares every one of them against the signed // artifact, so every one of them has to be on the screen — a number that is // verified but never displayed is verified against nothing the user agreed to. -function showTxFee(approvedTx, ethPrice) { +function showTxFee(approvedTx) { const network = networkByChainId(approvedTx.chainId); $("approve-tx-network").textContent = network ? network.name @@ -215,12 +216,9 @@ function showTxFee(approvedTx, ethPrice) { const gasLimit = BigInt(approvedTx.gasLimit); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); - const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas)); - const usdStr = formatUsd( - ethPrice ? parseFloat(maxFeeEth) * ethPrice : null, - ); - $("approve-tx-fee").textContent = - maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : ""); + // Through formatFee(), as the confirmation screen's fee is, so the same + // fee reads the same on both. + $("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas); let detail = gasLimit.toString() + @@ -332,7 +330,7 @@ function showTxApproval(details) { $("approve-tx-value").textContent = ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : ""); - showTxFee(approvedTx, ethPrice); + showTxFee(approvedTx); // Decode calldata (reuse decoded from above) const decodedEl = $("approve-tx-decoded"); diff --git a/src/popup/views/confirmTx.js b/src/popup/views/confirmTx.js index 338cbde..a176f43 100644 --- a/src/popup/views/confirmTx.js +++ b/src/popup/views/confirmTx.js @@ -15,6 +15,7 @@ const { attachCopyHandlers, goBack, onViewLeave, + formatFee, } = require("./helpers"); const { state } = require("../../shared/state"); const { getSignerForAddress } = require("../../shared/wallet"); @@ -31,6 +32,9 @@ const { transferAmountUnits, } = require("../../shared/transferAmount"); const { assertWithinCeilings } = require("../../shared/approvalVerify"); +// The balance lines, the fee reserve and the insufficient-balance messages go +// through it, as the approval screen's amounts do. +const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { CODES, FEE_PENDING, @@ -150,11 +154,17 @@ function show(txInfo) { $("confirm-balance").textContent = bal == null ? "unknown (" + symbol + ")" - : valueWithUsd(bal + " " + symbol, balUsd); + : valueWithUsd( + truncateAmountNeverZero(bal) + " " + symbol, + balUsd, + ); } else { const bal = txInfo.balance || "0"; const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null; - $("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd); + $("confirm-balance").textContent = valueWithUsd( + truncateAmountNeverZero(bal) + " ETH", + balUsd, + ); } // Check for warnings (synchronous local checks) @@ -249,7 +259,7 @@ function renderValidation(txInfo) { : "Insufficient " + symbol + " balance. You have " + - txInfo.tokenBalance + + truncateAmountNeverZero(txInfo.tokenBalance) + " " + symbol + " but are trying to send " + @@ -262,7 +272,7 @@ function renderValidation(txInfo) { if (codes.includes(CODES.INSUFFICIENT_ETH)) { messages.push( "Insufficient balance. You have " + - txInfo.balance + + truncateAmountNeverZero(txInfo.balance || "0") + " ETH but are trying to send " + txInfo.amount + " ETH.", @@ -305,14 +315,6 @@ 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, state.networkId); @@ -359,26 +361,20 @@ async function estimateGas(txInfo) { // flight; a stale fee must not reach the screen or the balance check. if (pendingTx !== txInfo) return; - const ethPrice = getPrice("ETH"); - const usd = (wei) => - ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null; - + // The fee lines go through formatFee(), as the approval screen's + // does, so the same fee reads the same on both. if (estimateWei !== null && estimateWei < gasCostWei) { - $("confirm-fee-amount").textContent = valueWithUsd( - "~" + formatFeeEth(estimateWei), - usd(estimateWei), - ); + $("confirm-fee-amount").textContent = "~" + formatFee(estimateWei); $("confirm-fee-reserve").textContent = - "up to " + formatFeeEth(gasCostWei) + " reserved"; + "up to " + + truncateAmountNeverZero(formatEther(gasCostWei)) + + " ETH 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), - ); + $("confirm-fee-amount").textContent = formatFee(gasCostWei); setVisible("confirm-fee-reserve", false); } feeStatus = FEE_KNOWN; diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index f1e5f9f..47c4729 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -12,6 +12,8 @@ // escapeHtml lives in src/shared/html.js, where the escape and the // reasoning behind it are; it is re-exported below so views keep importing // it from here. +const { formatEther } = require("ethers"); +const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { DEBUG } = require("../../shared/constants"); const { escapeHtml } = require("../../shared/html"); const { isDebug } = require("../../shared/log"); @@ -243,6 +245,19 @@ function unknownableAmount(balance) { return Number.isFinite(n) ? n : null; } +// A network fee in wei as the confirmation and approval screens both show it: +// the ETH figure through truncateAmountNeverZero(), then its USD value when the +// ETH price is known. The USD value is of the exact fee, not of the truncated +// figure, so it never understates what the fee costs. +function formatFee(wei) { + const eth = formatEther(wei); + const ethPrice = getPrice("ETH"); + const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; + return ( + truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "") + ); +} + // One row of the balance list: symbol, quantity, fiat value. // // `symbol` is the ERC-20's own symbol() as the block explorer reported it, @@ -610,6 +625,7 @@ module.exports = { balanceLinesForAddress, addressHoldsFunds, unknownableAmount, + formatFee, addressColor, addressDotHtml, escapeHtml, diff --git a/src/popup/views/send.js b/src/popup/views/send.js index fee2895..62fbabc 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -16,6 +16,7 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount"); const { resolveSymbol } = require("../../shared/tokenList"); const { isLowHolderCount } = require("../../shared/holders"); const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); +const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { getAddress } = require("ethers"); const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -150,7 +151,9 @@ function updateSendBalance() { const token = state.selectedToken || $("send-token").value; if (token === "ETH") { $("send-balance").textContent = - "Current balance: " + (addr.balance || "0") + " ETH"; + "Current balance: " + + truncateAmountNeverZero(addr.balance || "0") + + " ETH"; } else { const tb = (addr.tokenBalances || []).find( (t) => t.address.toLowerCase() === token.toLowerCase(), @@ -167,7 +170,10 @@ function updateSendBalance() { $("send-balance").textContent = bal == null ? "Current balance: unknown (" + symbol + ")" - : "Current balance: " + bal + " " + symbol; + : "Current balance: " + + truncateAmountNeverZero(bal) + + " " + + symbol; } } diff --git a/src/shared/balances.js b/src/shared/balances.js index 0e9ad5c..662ffce 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -52,14 +52,12 @@ function requireNetworkId(networkId) { return net; } -function formatBalance(wei) { - const eth = formatEther(wei); - const parts = eth.split("."); - if (parts.length === 1) return eth + ".0"; - const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; - return parts[0] + "." + dec; -} - +// A token balance cut to six decimal places. Two things rely on the cut: a +// holding below 0.000001 comes out as "0.0" and is left off the balance list as +// dust, and the stored string never carries more decimals than the balance +// check on the confirmation screen can read (18, in txValidation.js), whatever +// scale the token declares. Screens truncate it again for display, through +// src/shared/amountDisplay.js. function formatTokenBalance(raw, decimals) { const val = formatUnits(raw, decimals); const parts = val.split("."); @@ -221,7 +219,10 @@ async function refreshBalances( provider .getBalance(addr.address) .then((bal) => { - addr.balance = formatBalance(bal); + // Exact, never cut: the screens truncate for display + // themselves, and a cut here stores a small nonzero + // balance as zero. + addr.balance = formatEther(bal); log.debugf("ETH balance", addr.address, addr.balance); }) .catch((e) => { diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 48eb11c..a846ead 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -1414,9 +1414,10 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => { // on opposite sides of the reserve while sitting on the same side of the // estimate. -// The balance the funded fixture serves, and the amounts sent against it. +// The balance the funded fixture serves, as the Send and confirmation screens +// show it, and the amounts sent against it. const FUNDED_ETH_WEI = 10n ** 18n; -const FUNDED_ETH_TEXT = "1.0"; +const FUNDED_ETH_TEXT = "1.0000"; const COMFORTABLE_AMOUNT = "0.1"; const OVER_BALANCE_AMOUNT = "2.0"; @@ -1430,7 +1431,7 @@ const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI); // fee test: it covers the expected cost to the wei and falls short of the // reserve, so the same swap flips this assertion too — through a different // balance and a different message than the ETH path uses. -const TOKEN_BALANCE_TEXT = "1.5"; +const TOKEN_BALANCE_TEXT = "1.5000"; const TOKEN_AMOUNT = "0.25"; const OVER_TOKEN_AMOUNT = "9.0"; const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI; @@ -1439,16 +1440,15 @@ function toHexWei(wei) { return "0x" + wei.toString(16); } -// A fee in wei as the confirmation screen writes it. Deliberately a second -// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather -// than an import of it: that module pulls in the whole popup and cannot be -// required outside a browser, and asserting against an independent rendering -// is stronger than asserting a function equals itself. +// A fee in wei as the confirmation screen writes it: truncated to four decimal +// places (README.md, Display Consistency). Deliberately a second +// implementation rather than an import of src/shared/amountDisplay.js: +// asserting against an independent rendering is stronger than asserting a +// function equals itself. The fixture's fees are above 0.0001 ETH, so the +// nonzero floor never applies here. function feeEth(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"; + const [whole, frac = ""] = formatEther(wei).split("."); + return whole + "." + (frac + "0000").slice(0, 4) + " ETH"; } // What the confirmation screen is showing right now, read out of the DOM in @@ -1505,11 +1505,10 @@ async function backToAddress(page) { // Drive the popup to the confirmation screen for one send. // -// It waits for the send screen to be showing `balance` before filling -// anything in. That figure is the exact number the spend gate compares -// against, so waiting for it — rather than for a refresh to have probably -// landed — is what keeps every assertion below deterministic after a -// fixture change. +// It waits for the send screen to be showing `balance`, the fixture's balance +// as that screen displays it, before filling anything in. Waiting for it — +// rather than for a refresh to have probably landed — is what keeps every +// assertion below deterministic after a fixture change. async function goToConfirm(page, { token, balance, amount }) { await backToAddress(page); await page.click("#btn-send"); diff --git a/tests/sendDisplayFloor.test.js b/tests/sendDisplayFloor.test.js new file mode 100644 index 0000000..969bfa4 --- /dev/null +++ b/tests/sendDisplayFloor.test.js @@ -0,0 +1,284 @@ +// The balance and fee lines of the Send and confirmation screens, and the fee +// line they must share with the approval screen. +// +// An ETH balance or a fee below 0.000001 rendered as `0.0` on these screens +// (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored balance and the +// fee were each cut to six decimal places, a rule of their own, while the +// approval screen showed the same fee through src/shared/amountDisplay.js with +// the nonzero floor. Both now go through that one helper. +// +// Driven through the real refreshBalances(), Send screen, confirmation screen +// and approval screen, with only the node and the DOM stubbed: a balance +// written onto state by hand would skip the place the cut happened. + +"use strict"; + +// What the stub node answers. Each test sets what it needs. +const mockNode = { + balanceWei: 0n, + feeData: { maxFeePerGas: 1n, gasPrice: 1n }, +}; + +jest.mock("ethers", () => { + const actual = jest.requireActual("ethers"); + class StubProvider { + async getBalance() { + return mockNode.balanceWei; + } + async lookupAddress() { + return null; + } + async getFeeData() { + return mockNode.feeData; + } + async estimateGas() { + return 21000n; + } + async getCode() { + return "0x"; + } + async getTransactionCount() { + return 1; + } + } + return { + ...actual, + JsonRpcProvider: StubProvider, + Network: { from: () => ({}) }, + }; +}); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + // The explorer's token list, which refreshBalances() also fetches: empty. + debugFetch: jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => [], + })), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +// The confirmation screen's Etherscan label lookup is the only fetch() these +// screens make; it fails, as it does offline. +global.fetch = jest.fn(() => { + throw new Error("tests must not perform network requests"); +}); + +// The approval the background hands the approval screen. Set per test. +let approvalDetails = null; + +const { makeStorageStub } = require("./support/storageStub"); +global.chrome = { + storage: makeStorageStub(), + runtime: { + connect: () => ({ + postMessage() {}, + disconnect() {}, + onDisconnect: { addListener() {} }, + }), + sendMessage(message, callback) { + callback( + message.type === "AUTISTMASK_GET_APPROVAL" + ? approvalDetails + : undefined, + ); + }, + }, +}; + +// A stub DOM: every id resolves to a recording element. +const elements = new Map(); + +function makeEl(id) { + const handlers = new Map(); + return { + id, + textContent: "", + innerHTML: "", + value: "", + disabled: false, + style: {}, + dataset: {}, + classList: { + add() {}, + remove() {}, + toggle() {}, + contains: () => false, + }, + handlers, + addEventListener(name, fn) { + handlers.set(name, fn); + }, + appendChild(child) { + return child; + }, + querySelectorAll: () => [], + querySelector: () => null, + remove() {}, + focus() {}, + }; +} + +global.document = { + getElementById(id) { + if (!elements.has(id)) elements.set(id, makeEl(id)); + return elements.get(id); + }, + createElement: (tag) => makeEl(tag), + body: { prepend() {}, appendChild() {} }, + addEventListener() {}, +}; +global.navigator = { clipboard: { writeText() {} } }; + +const { refreshBalances } = require("../src/shared/balances"); +const { state } = require("../src/shared/state"); +const { prices, clearPrices } = require("../src/shared/prices"); +const send = require("../src/popup/views/send"); +const confirmTx = require("../src/popup/views/confirmTx"); +const approval = require("../src/popup/views/approval"); + +const HOLDER = "0x" + "a".repeat(40); +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; + +// 0.0000005 ETH. +const HALF_MICRO_ETH = 500000000000n; + +function text(id) { + return global.document.getElementById(id).textContent; +} + +// The ETH balance the node reports, fetched and stored exactly where the popup +// stores it. +async function refreshWith(balanceWei) { + mockNode.balanceWei = balanceWei; + state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; + state.selectedWallet = 0; + state.selectedAddress = 0; + await refreshBalances( + state.wallets, + "https://rpc.example.invalid", + "https://blockscout.example/api/v2", + [], + "mainnet", + ); +} + +// Press Review on the Send screen for an ETH send, and show the confirmation +// screen it leads to with its fee estimate settled. +async function confirmEthSend(amount) { + let txInfo = null; + send.init({ showConfirmTx: (info) => (txInfo = info) }); + state.selectedToken = "ETH"; + global.document.getElementById("send-to").value = RECIPIENT; + global.document.getElementById("send-amount").value = amount; + await global.document + .getElementById("btn-send-review") + .handlers.get("click")(); + confirmTx.show(txInfo); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); +} + +// The approval screen for a dApp transaction of 21000 gas, the gas the stub +// node estimates for the send above. +async function approveTxWithFeePerGas(maxFeePerGas) { + approvalDetails = { + type: "tx", + hostname: "dapp.example", + approvedFrom: HOLDER, + approvedTx: { + to: RECIPIENT, + value: "0", + data: "0x", + chainId: "0x1", + gasLimit: "21000", + maxFeePerGas: String(maxFeePerGas), + nonce: 0, + }, + }; + await approval.show("1"); +} + +beforeEach(() => { + elements.clear(); + state.selectedToken = null; + state.trackedTokens = []; + state.fraudContracts = []; + state.currentView = null; + mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n }; +}); + +describe("an ETH balance below 0.000001 never renders as zero", () => { + test("on the Send screen", async () => { + await refreshWith(HALF_MICRO_ETH); + state.selectedToken = "ETH"; + send.updateSendBalance(); + expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH"); + }); + + test("on the confirmation screen", async () => { + await refreshWith(HALF_MICRO_ETH); + await confirmEthSend("0.0000001"); + expect(text("confirm-balance")).toBe("0.0000005 ETH"); + }); + + test("while a balance above the floor keeps four decimals", async () => { + await refreshWith(1234567890000000000n); + await confirmEthSend("0.1"); + expect(text("confirm-balance")).toBe("1.2345 ETH"); + }); +}); + +describe("a fee below 0.000001 ETH never renders as zero", () => { + test("when the estimate and the reserve are the same", async () => { + await refreshWith(10n ** 18n); + await confirmEthSend("0.1"); + // 21000 gas at 1 wei is 0.000000000000021 ETH, shown to its first + // significant digit. + expect(text("confirm-fee-amount")).toBe("0.00000000000002 ETH"); + }); + + test("when they differ, on both lines", async () => { + mockNode.feeData = { maxFeePerGas: 2n, gasPrice: 1n }; + await refreshWith(10n ** 18n); + await confirmEthSend("0.1"); + expect(text("confirm-fee-amount")).toBe("~0.00000000000002 ETH"); + expect(text("confirm-fee-reserve")).toBe( + "up to 0.00000000000004 ETH reserved", + ); + }); +}); + +// The confirmation screen shows the reserve alone when the node quotes no +// cheaper estimate, and that reserve is the same gas limit times maximum fee +// per gas that the approval screen calls the max fee. An ETH price is set, as +// it is on mainnet, so the USD value has to match too. +describe("the same fee reads the same on the confirmation and approval screens", () => { + beforeEach(() => { + prices.ETH = 3000; + }); + afterEach(() => { + clearPrices(); + }); + + test.each([ + // 21000 gas at 1 wei. + ["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"], + // 0.001235294117631 ETH, which is $3.71. Pricing the truncated + // 0.0012 instead would read $3.60. + ["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"], + ])("%s", async (_label, feePerGas, expected) => { + mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas }; + await refreshWith(10n ** 18n); + await confirmEthSend("0.1"); + expect(text("confirm-fee-amount")).toBe(expected); + await approveTxWithFeePerGas(feePerGas); + expect(text("approve-tx-fee")).toBe(expected); + }); +}); diff --git a/tests/unknownScaleSend.test.js b/tests/unknownScaleSend.test.js index 9be1839..2c326d7 100644 --- a/tests/unknownScaleSend.test.js +++ b/tests/unknownScaleSend.test.js @@ -389,7 +389,7 @@ describe("a scale the explorer's own rows disagree about", () => { expect(txInfo.tokenBalance).toBe("5.0"); confirmTx.show(txInfo); await settle(); - expect(text("confirm-balance")).toBe("5.0 NOVEL"); + expect(text("confirm-balance")).toBe("5.0000 NOVEL"); expect(errors()).toBe(""); expect(sendDisabled()).toBe(false); }); @@ -431,7 +431,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", () const zero = await render("0.0"); expect(unknown.balance).not.toBe(zero.balance); expect(unknown.balance).toBe("unknown (NOVEL)"); - expect(zero.balance).toBe("0.0 NOVEL"); + expect(zero.balance).toBe("0.0000 NOVEL"); }); // Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to @@ -443,7 +443,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", () expect(unknown.errors).not.toBe(zero.errors); expect(unknown.errors).toContain("This token's balance is unknown"); expect(unknown.errors).not.toContain("You have"); - expect(zero.errors).toContain("You have 0.0 NOVEL"); + expect(zero.errors).toContain("You have 0.0000 NOVEL"); expect(zero.errors).not.toContain("balance is unknown"); }); });