diff --git a/README.md b/README.md index f94a9de..39e527d 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,23 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully offline; unrecognised outbound requests are reported as failures rather than silently allowed. +It also covers the confirmation screen, for both a native ETH send and an ERC-20 +send: Send disabled while the fee estimate is in flight, enabled once it lands, +the fee block quoting the expected cost and the reserve separately, the distinct +message for an estimate that failed, and the view height staying constant across +every one of those transitions. The load-bearing one is that the spend gate uses +the **reserve** and not the displayed **estimate** — the two are stubbed far +apart on purpose, and the funded and refused sends sit on opposite sides of the +reserve while sitting on the same side of the estimate, so swapping the two in +`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is +what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was +previously correct by reading only. + +Any test that drives a failure path on purpose declares the `console.error` it +is about to provoke, via `errors.expect()`. That is not a mute: the declaration +consumes exactly one matching record, and a declaration nothing matched fails +its test just as an undeclared error does. + That reporting has one bound worth knowing. Observation ends when the browser context is torn down, and nothing can watch traffic after that, so the run keeps collecting for a fixed grace period after the last test returns diff --git a/TODO.md b/TODO.md index 0e177a1..9c81ac1 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,14 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-12: The transaction confirmation screen has browser coverage. The + end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path + off a funded-balance fixture, and asserts the pending, funded, over-balance + and estimate-failed states, the fee block quoting the estimate and the reserve + separately, and a constant view height across every one of those transitions. + The load-bearing assertion is that the spend gate reads the reserve and not + the displayed estimate: swapping the two fails the suite + ([#238](https://git.eeqj.de/sneak/AutistMask/issues/238)). - 2026-08-12: Approval verification became an allowlist — transaction type restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an approved transfer, every consequential field compared, the artifact diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js index 20f3e4f..6758c1c 100644 --- a/tests/e2e/harness.js +++ b/tests/e2e/harness.js @@ -53,15 +53,47 @@ function isAllowed(text) { // after that — the route handler and the console listeners are gone with // the context — so there is no post-teardown phase to collect, and this // class deliberately offers no mechanism pretending to cover one. +// +// One narrow exception exists, and it is not a mute: expect(). A test that +// drives a failure path on purpose — a refused gas estimate, say — provokes +// the console.error the code is supposed to emit, and that error is the +// behaviour under test rather than an escape. Declaring it consumes exactly +// one matching record and no more, and an expectation nothing matched fails +// its test just as an unexpected error does. So it cannot be used to +// silence anything: it can only assert that a specific error happened. class ErrorCollector { constructor() { this.entries = []; this.taken = 0; + this.expectations = []; + } + + // Declare a console.error this test is about to cause deliberately. + // `label` names it in the failure message if it never arrives. + expect(label, pattern) { + this.expectations.push({ label, pattern, matched: false }); + } + + // Declared expectations that nothing matched, clearing the list so each + // test starts with none outstanding. + unmatchedExpectations() { + const out = this.expectations + .filter((e) => !e.matched) + .map((e) => e.label); + this.expectations = []; + return out; } record(kind, text) { const line = kind + ": " + String(text).split("\n")[0]; if (isAllowed(line)) return; + const expected = this.expectations.find( + (e) => !e.matched && e.pattern.test(line), + ); + if (expected) { + expected.matched = true; + return; + } this.entries.push(line); } @@ -290,13 +322,18 @@ async function createWallet(page) { return phrase; } -// Reach the address detail screen from wherever the popup restored to. -// Clicking .address-row does not open it; the [info] button does. +// Reach the address detail screen of the FIRST address of the first wallet, +// from wherever the popup restored to. Clicking .address-row does not open +// it; the [info] button does. +// +// .first() rather than a bare selector because the suite adds a second +// wallet partway through, and every later test would otherwise die in +// Playwright's strict mode rather than on an assertion. async function openAddressDetail(page) { const onAddress = await page.isVisible("#view-address"); if (!onAddress) { await visible(page, "#view-main"); - await page.click("#wallet-list .btn-addr-info"); + await page.locator("#wallet-list .btn-addr-info").first().click(); } await visible(page, "#view-address"); } diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 034e940..d04b57a 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -53,18 +53,96 @@ const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; // log.errorf(), i.e. console.error, which fails the run on its own. const ZERO_WORD = "0x" + "0".repeat(64); +function hex(value) { + return "0x" + BigInt(value).toString(16); +} + +// A bigint as a 32-byte ABI word. +function word(value) { + return "0x" + BigInt(value).toString(16).padStart(64, "0"); +} + +// ------------------------------------------------------------ fee fixture +// +// The confirmation screen carries two different numbers for the same +// transaction and may gate on only one of them: +// +// reserve = gasLimit * maxFeePerGas — what a node requires to be +// available for a type-2 transaction, and what the spend gate +// must use. +// estimate = gasLimit * gasPrice — what the transfer is expected to +// actually cost. Display only. +// +// Issue #154 was the gate reading the smaller of the two. ethers derives +// maxFeePerGas as baseFeePerGas * 2 + maxPriorityFeePerGas, so the numbers +// below put the reserve at very nearly twice the estimate. That gap is the +// entire point of these values: it leaves room for a send that an +// estimate-based gate accepts and a reserve-based gate refuses, which is +// what lets the ConfirmTx tests tell the two apart at all. Collapse the gap +// — by dropping baseFeePerGas from the block below, say — and those tests +// go on passing while asserting nothing. +const GAS_LIMIT = 21000n; +const BASE_FEE_WEI = 100000000000n; // 100 gwei +const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei +const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; // 101 gwei +const MAX_FEE_WEI = BASE_FEE_WEI * 2n + PRIORITY_FEE_WEI; // 201 gwei + +const FEE_ESTIMATE_WEI = GAS_LIMIT * GAS_PRICE_WEI; // 0.002121 ETH +const FEE_RESERVE_WEI = GAS_LIMIT * MAX_FEE_WEI; // 0.004221 ETH + const RPC_RESULTS = { eth_chainId: "0x1", net_version: "1", eth_blockNumber: "0x1406f40", eth_getBalance: "0x0", eth_call: ZERO_WORD, - eth_gasPrice: "0x3b9aca00", - eth_estimateGas: "0x5208", + eth_getCode: "0x", + eth_gasPrice: hex(GAS_PRICE_WEI), + eth_estimateGas: hex(GAS_LIMIT), eth_getTransactionCount: "0x0", - eth_maxPriorityFeePerGas: "0x3b9aca00", + eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), }; +// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from +// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the +// reserve and the estimate collapse to the same number, and the gate tests +// stop being able to distinguish them. +function latestBlock() { + return { + hash: "0x" + "11".repeat(32), + parentHash: "0x" + "22".repeat(32), + number: hex(STUB_BLOCK_NUMBER), + timestamp: hex(1767326645), + nonce: "0x0000000000000000", + difficulty: "0x0", + gasLimit: "0x1c9c380", + gasUsed: "0xf4240", + miner: STUB_COUNTERPARTY, + extraData: "0x", + baseFeePerGas: hex(BASE_FEE_WEI), + transactions: [], + }; +} + +// keccak("decimals()")[0:4]. +const SELECTOR_DECIMALS = "0x313ce567"; + +// Every eth_call still answers with a zero word except decimals() on the +// stub token. ethers reads that before it can encode an ERC-20 transfer, +// and a zero there makes parseUnits() reject any fractional amount — so the +// ERC-20 confirmation path would fail its gas estimate for a reason that +// has nothing to do with what is being tested. +function ethCallResult(req) { + const call = Array.isArray(req.params) ? req.params[0] : null; + if (!call || typeof call !== "object") return ZERO_WORD; + const data = String(call.data || call.input || "").toLowerCase(); + const to = String(call.to || "").toLowerCase(); + if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { + return word(STUB_TOKEN.decimals); + } + return ZERO_WORD; +} + function tokenObject() { return { address_hash: STUB_TOKEN.address, @@ -92,6 +170,18 @@ function tokenTransferItems(address) { ]; } +// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving +// this is what puts an ERC-20 in the send screen's token dropdown, which is +// the only way the confirmation screen's ERC-20 path can be reached. +function tokenBalanceItems() { + return [ + { + value: "1500000", + token: tokenObject(), + }, + ]; +} + // Full details for STUB_TX_HASH. raw_input is "0x" so the calldata // decoder short-circuits; the on-chain detail fields still populate. function transactionDetails() { @@ -121,7 +211,82 @@ function blockscoutAddress(pathname) { return m ? m[1] : null; } -function handleRpc(route, postData, report) { +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// How long a deliberately held reply is allowed to stay held, and how often +// the release flag is re-read while it is. +const HOLD_POLL_MS = 25; +const HOLD_MAX_MS = 30000; + +// Hold a gas estimate open for as long as the test asks. +// +// opts.holdGasEstimate is read here rather than captured, so a test flips it +// on the same options object the route was registered with — the same +// pattern as seedTokenTransfer. This is the only way to observe the +// confirmation screen while its estimate is genuinely in flight; sampling +// the screen and hoping to win a race against the network would assert +// nothing on a slow machine. +// +// It never gives up quietly. A hold that outlives the bound is reported like +// any other harness fault, because a "pending" state that stopped being +// pending on its own is a green assertion about the wrong screen. +async function awaitRelease(opts, report) { + const started = Date.now(); + while (opts.holdGasEstimate) { + if (Date.now() - started > HOLD_MAX_MS) { + report( + "held gas estimate was never released after " + + HOLD_MAX_MS + + "ms", + ); + return; + } + await sleep(HOLD_POLL_MS); + } +} + +// One JSON-RPC reply. Methods whose answer depends on a fixture a test has +// set, or on the call itself, are resolved here; every other method is a +// constant in RPC_RESULTS. +function rpcReply(req, opts, report) { + const envelope = { jsonrpc: "2.0", id: req.id }; + + if (req.method === "eth_getBalance") { + return Object.assign(envelope, { + result: opts.ethBalanceWei || RPC_RESULTS.eth_getBalance, + }); + } + if (req.method === "eth_call") { + return Object.assign(envelope, { result: ethCallResult(req) }); + } + if (req.method === "eth_getBlockByNumber") { + return Object.assign(envelope, { result: latestBlock() }); + } + if (req.method === "eth_estimateGas" && opts.failGasEstimate) { + // A refusal the node itself would produce, not a transport error: + // this is the shape the confirmation screen has to turn into + // "Unable to estimate" rather than into a fee of zero. + return Object.assign(envelope, { + error: { + code: -32000, + message: "e2e fixture: gas required exceeds allowance", + }, + }); + } + + const result = RPC_RESULTS[req.method]; + if (result === undefined) { + report("unstubbed RPC method: " + req.method); + return Object.assign(envelope, { + error: { code: -32601, message: "unstubbed in e2e harness" }, + }); + } + return Object.assign(envelope, { result }); +} + +async function handleRpc(route, postData, opts, report) { let payload; try { payload = JSON.parse(postData || "null"); @@ -148,18 +313,11 @@ function handleRpc(route, postData, report) { report("unstubbed request: POST " + route.request().url()); return route.abort(); } - const replies = batch.map((req) => { - const result = RPC_RESULTS[req.method]; - if (result === undefined) { - report("unstubbed RPC method: " + req.method); - return { - jsonrpc: "2.0", - id: req.id, - error: { code: -32601, message: "unstubbed in e2e harness" }, - }; - } - return { jsonrpc: "2.0", id: req.id, result }; - }); + if (batch.some((req) => req.method === "eth_estimateGas")) { + await awaitRelease(opts, report); + } + + const replies = batch.map((req) => rpcReply(req, opts, report)); return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); } @@ -199,6 +357,15 @@ function traceEnabled(raw) { * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * transfer. Read at request time, so a test can flip it on the same * options object without re-registering the route. + * @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20 + * holding, which is what makes the token reachable from the send screen. + * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; + * defaults to zero, which is what every test that predates the funded + * fixture expects. + * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a + * node-side refusal. + * @param {boolean} [opts.holdGasEstimate] hold every batch containing an + * eth_estimateGas until this is cleared again. * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * Promise}>} */ @@ -241,7 +408,7 @@ async function installNetworkStubs(ctx, opts) { // JSON-RPC endpoint (any host): a POST with a JSON-RPC body. if (req.method() === "POST") { - return handleRpc(route, req.postData(), report); + return handleRpc(route, req.postData(), opts, report); } // Blockscout v2 @@ -259,7 +426,10 @@ async function installNetworkStubs(ctx, opts) { }); } if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { - return jsonResponse(route, []); + return jsonResponse( + route, + opts.seedTokenBalance ? tokenBalanceItems() : [], + ); } if (p.endsWith("/transactions/" + STUB_TX_HASH)) { return jsonResponse(route, transactionDetails()); @@ -329,6 +499,9 @@ async function installNetworkStubs(ctx, opts) { module.exports = { installNetworkStubs, + FEE_ESTIMATE_WEI, + FEE_RESERVE_WEI, + STUB_COUNTERPARTY, STUB_TOKEN, STUB_TX_HASH, }; diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 29a87f9..1c7fa37 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -9,6 +9,7 @@ "use strict"; +const { formatEther } = require("ethers"); const { PASSWORD, createWallet, @@ -18,7 +19,13 @@ const { pageCompilesWasm, visible, } = require("./harness"); -const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); +const { + FEE_ESTIMATE_WEI, + FEE_RESERVE_WEI, + STUB_COUNTERPARTY, + STUB_TOKEN, + STUB_TX_HASH, +} = require("./network"); const TEST_TIMEOUT_MS = 120000; @@ -493,6 +500,573 @@ test("confirming removes the address and returns Home (#162)", async (env) => { ); }); +// --------------------------------------------- confirmation screen (#238) +// +// The screen that decides what gets signed. The arithmetic underneath it +// lives in src/shared/txValidation.js and is unit tested there; what these +// tests cover is the wiring — which number reaches the gate, when the gate +// re-runs, what the fee block renders, and whether Send is enabled. +// +// The load-bearing one is "gates on the fee RESERVE": the confirmation +// screen quotes the ESTIMATE and gates on the RESERVE, and issue #154 was +// the gate reading the quoted number. Every other assertion here would +// survive that mutation, so the funded and gap sends are deliberately sized +// 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. +const FUNDED_ETH_WEI = 10n ** 18n; +const FUNDED_ETH_TEXT = "1.0"; +const COMFORTABLE_AMOUNT = "0.1"; +const OVER_BALANCE_AMOUNT = "2.0"; + +// A send the balance covers to the wei once the ESTIMATE is added, and does +// not cover once the RESERVE is. Sending this is allowed by a gate reading +// the estimate and refused by a gate reading the reserve, which is the whole +// discrimination these tests exist to make. +const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI); + +// The ERC-20 side. The ETH balance is set to exactly the estimate for the +// 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_AMOUNT = "0.25"; +const OVER_TOKEN_AMOUNT = "9.0"; +const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI; + +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. +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"; +} + +// What the confirmation screen is showing right now, read out of the DOM in +// one pass: whether sending is allowed, which reason it is giving, what the +// fee block says, and how tall the whole view is. +async function confirmState(page) { + return page.evaluate(() => { + const el = (id) => document.getElementById(id); + // Both mechanisms matter. The two fee messages are dropped with + // display:none for the transaction type they cannot apply to, and + // shown or hidden with visibility for the one they can. + const shown = (id) => { + const cs = getComputedStyle(el(id)); + return cs.display !== "none" && cs.visibility === "visible"; + }; + return { + height: el("view-confirm-tx").getBoundingClientRect().height, + type: el("confirm-type").textContent.trim(), + balance: el("confirm-balance").textContent.trim(), + fee: el("confirm-fee-amount").textContent.trim(), + reserve: el("confirm-fee-reserve").textContent.trim(), + reserveShown: shown("confirm-fee-reserve"), + errors: shown("confirm-errors") + ? el("confirm-errors").textContent.trim() + : "", + amountFeeError: shown("confirm-amount-fee-error"), + gasError: shown("confirm-gas-error"), + feeUnknownError: shown("confirm-fee-unknown-error"), + sendDisabled: el("btn-confirm-send").disabled, + }; + }); +} + +async function waitForEstimate(page) { + await page.waitForFunction( + () => + document.getElementById("confirm-fee-amount").textContent.trim() !== + "Estimating...", + null, + { timeout: 60000 }, + ); +} + +async function backToAddress(page) { + if (await page.isVisible("#view-confirm-tx")) { + await page.click("#btn-confirm-back"); + await visible(page, "#view-send"); + } + if (await page.isVisible("#view-send")) { + await page.click("#btn-send-back"); + } + await openAddressDetail(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. +async function goToConfirm(page, { token, balance, amount }) { + await backToAddress(page); + await page.click("#btn-send"); + await visible(page, "#view-send"); + await page.selectOption("#send-token", token); + await page.waitForFunction( + (want) => + document.getElementById("send-balance").textContent.trim() === want, + "Current balance: " + balance, + { timeout: 60000 }, + ); + await page.fill("#send-to", STUB_COUNTERPARTY); + await page.fill("#send-amount", amount); + await page.click("#btn-send-review"); + await visible(page, "#view-confirm-tx"); +} + +// A balance as the main view renders it: balanceLinesForAddress() writes +// every quantity with four decimal places. +function quantity(wei) { + return parseFloat(formatEther(wei)).toFixed(4); +} + +// Wait on the main view until a changed balance fixture has been picked up. +// +// Deliberately not a reload: the popup re-refreshes on a 10-second timer by +// itself, and reloading aborts whatever fetch the home screen has open at +// that instant, which the extension reports through log.errorf and the +// harness — correctly — fails the run on. +// +// It also deliberately settles on MAIN rather than on the address screen. +// The address screen builds the send screen's token dropdown once, from the +// balances it holds at that moment, and nothing rebuilds it when a later +// refresh arrives, so entering it early leaves a dropdown with no token in +// it and the ERC-20 path unreachable. +async function settleOnMain(env, { ethWei, expectToken }) { + await backToAddress(env.page); + await env.page.click("#btn-address-back"); + await visible(env.page, "#view-main"); + await env.page.waitForFunction( + (want) => + document.getElementById("wallet-list").textContent.includes(want), + quantity(ethWei), + { timeout: 60000 }, + ); + if (expectToken) { + await visible( + env.page, + '#wallet-list [data-token="' + STUB_TOKEN.address + '"]', + 60000, + ); + } +} + +test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async (env) => { + env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI); + env.routeOpts.seedTokenBalance = true; + await settleOnMain(env, { + ethWei: FUNDED_ETH_WEI, + expectToken: true, + }); + + env.routeOpts.holdGasEstimate = true; + await goToConfirm(env.page, { + token: "ETH", + balance: FUNDED_ETH_TEXT + " ETH", + amount: COMFORTABLE_AMOUNT, + }); + + const st = await confirmState(env.page); + env.ethPendingHeight = st.height; + console.log("# confirm-tx ETH view height: " + st.height + "px"); + assert( + st.type === "Native ETH transfer", + "unexpected transaction type: " + JSON.stringify(st.type), + ); + assert( + st.fee === "Estimating...", + "the fee line is not showing the pending placeholder: " + + JSON.stringify(st.fee), + ); + assert(!st.reserveShown, "the reserve line is shown before any estimate"); + assert( + st.sendDisabled, + "Send is enabled while the fee estimate is still in flight", + ); + assert( + !st.feeUnknownError, + "the estimate-failed message is shown for an estimate that is merely pending", + ); + assert( + !st.amountFeeError && !st.gasError && st.errors === "", + "a balance message is shown before the fee is known", + ); +}); + +test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238)", async (env) => { + env.routeOpts.holdGasEstimate = false; + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + assert( + st.balance === FUNDED_ETH_TEXT + " ETH", + "the confirmation screen shows the wrong balance: " + + JSON.stringify(st.balance), + ); + assert( + st.fee === "~" + feeEth(FEE_ESTIMATE_WEI), + "the fee line does not quote the estimate: " + JSON.stringify(st.fee), + ); + assert(st.reserveShown, "the reserve line is not shown once the fee lands"); + assert( + st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved", + "the reserve line does not quote the reserve: " + + JSON.stringify(st.reserve), + ); + assert( + !st.sendDisabled, + "Send is disabled for a comfortably funded transfer", + ); + assert( + st.errors === "" && + !st.amountFeeError && + !st.gasError && + !st.feeUnknownError, + "a balance message is shown for a comfortably funded transfer", + ); + assert( + st.height === env.ethPendingHeight, + "the view changed height when the estimate landed: " + + env.ethPendingHeight + + "px -> " + + st.height + + "px", + ); +}); + +// The one that closes the hole. Everything else here survives a gate that +// reads the displayed estimate instead of the reserve; this does not. +test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", async (env) => { + await goToConfirm(env.page, { + token: "ETH", + balance: FUNDED_ETH_TEXT + " ETH", + amount: GAP_AMOUNT, + }); + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + // Printed on every run, pass or fail: the two fee numbers and the gate's + // decision side by side is the measurement this test is really making. + console.log( + "# gate probe: balance=" + + FUNDED_ETH_TEXT + + " ETH amount=" + + GAP_AMOUNT + + " estimate=" + + feeEth(FEE_ESTIMATE_WEI) + + " reserve=" + + feeEth(FEE_RESERVE_WEI) + + " sendDisabled=" + + st.sendDisabled + + " amountFeeError=" + + st.amountFeeError, + ); + assert( + st.fee === "~" + feeEth(FEE_ESTIMATE_WEI), + "the screen is not quoting the estimate, so this send is not in the gap: " + + JSON.stringify(st.fee), + ); + assert( + st.sendDisabled, + "Send is ENABLED for a transfer the fee RESERVE does not cover — the " + + "spend gate is reading the displayed estimate, which is issue #154", + ); + assert( + st.amountFeeError, + "the amount-plus-fee message is not shown for a send the reserve does not cover", + ); + assert( + st.height === env.ethPendingHeight, + "the over-budget state is a different height than the pending state: " + + env.ethPendingHeight + + "px -> " + + st.height + + "px", + ); +}); + +test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => { + await goToConfirm(env.page, { + token: "ETH", + balance: FUNDED_ETH_TEXT + " ETH", + amount: OVER_BALANCE_AMOUNT, + }); + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + const want = + "Insufficient balance. You have " + + FUNDED_ETH_TEXT + + " ETH but are trying to send " + + OVER_BALANCE_AMOUNT + + " ETH."; + assert( + st.errors === want, + "wrong over-balance message: " + JSON.stringify(st.errors), + ); + assert(st.sendDisabled, "Send is enabled for a send that exceeds balance"); + assert( + !st.amountFeeError, + "the amount-plus-fee message is shown for an amount that alone exceeds the balance", + ); +}); + +test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => { + // The refusal is logged by confirmTx via log.errorf, i.e. console.error, + // which fails a test on its own. Declaring it here consumes exactly that + // one record — and fails this test if it never arrives. + env.errors.expect( + "confirmTx logging the failed gas estimate", + /gas estimation failed/, + ); + env.routeOpts.failGasEstimate = true; + env.routeOpts.holdGasEstimate = true; + await goToConfirm(env.page, { + token: "ETH", + balance: FUNDED_ETH_TEXT + " ETH", + amount: COMFORTABLE_AMOUNT, + }); + + const pending = await confirmState(env.page); + assert( + pending.fee === "Estimating..." && pending.sendDisabled, + "the screen is not in the pending state before the estimate fails", + ); + + env.routeOpts.holdGasEstimate = false; + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + env.routeOpts.failGasEstimate = false; + assert( + st.fee === "Unable to estimate", + "the fee line does not report the failure: " + JSON.stringify(st.fee), + ); + assert( + !st.reserveShown, + "the reserve line is shown after a failed estimate", + ); + assert( + st.feeUnknownError, + "the estimate-failed message is not shown after a failed estimate", + ); + assert( + !st.amountFeeError && !st.gasError && st.errors === "", + "a balance message is shown for an estimate that simply failed", + ); + assert( + st.sendDisabled, + "Send is enabled with no usable fee estimate — an unknown fee is being treated as zero", + ); + assert( + st.height === pending.height, + "the view changed height when the estimate failed: " + + pending.height + + "px -> " + + st.height + + "px", + ); +}); + +test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => { + env.routeOpts.holdGasEstimate = true; + await goToConfirm(env.page, { + token: STUB_TOKEN.address, + balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, + amount: TOKEN_AMOUNT, + }); + + const pending = await confirmState(env.page); + env.erc20PendingHeight = pending.height; + console.log("# confirm-tx ERC-20 view height: " + pending.height + "px"); + assert( + pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")", + "unexpected transaction type: " + JSON.stringify(pending.type), + ); + assert( + pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, + "the ERC-20 screen shows the wrong balance: " + + JSON.stringify(pending.balance), + ); + assert( + pending.fee === "Estimating..." && pending.sendDisabled, + "the ERC-20 screen does not block sending while its estimate is pending", + ); + + env.routeOpts.holdGasEstimate = false; + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + assert( + st.fee === "~" + feeEth(FEE_ESTIMATE_WEI), + "the ERC-20 fee line does not quote the estimate: " + + JSON.stringify(st.fee), + ); + assert( + st.reserveShown && + st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved", + "the ERC-20 fee block does not quote the reserve: " + + JSON.stringify(st.reserve), + ); + assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer"); + assert( + st.height === pending.height, + "the ERC-20 view changed height when the estimate landed: " + + pending.height + + "px -> " + + st.height + + "px", + ); +}); + +test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => { + await goToConfirm(env.page, { + token: STUB_TOKEN.address, + balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, + amount: OVER_TOKEN_AMOUNT, + }); + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + const want = + "Insufficient " + + STUB_TOKEN.symbol + + " balance. You have " + + TOKEN_BALANCE_TEXT + + " " + + STUB_TOKEN.symbol + + " but are trying to send " + + OVER_TOKEN_AMOUNT + + " " + + STUB_TOKEN.symbol + + "."; + assert( + st.errors === want, + "wrong over-token-balance message: " + JSON.stringify(st.errors), + ); + assert( + st.sendDisabled, + "Send is enabled for an ERC-20 transfer that exceeds the token balance", + ); + assert( + !st.gasError, + "the ERC-20 gas message is shown for an ETH balance that covers the fee", + ); +}); + +// The same swap, through the other balance and the other message: here the +// token balance is ample and it is the ETH balance that must cover the fee. +// It is set to exactly the estimate, so an estimate-reading gate lets this +// through and the reserve-reading gate refuses it. +test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#238)", async (env) => { + env.routeOpts.ethBalanceWei = toHexWei(FEE_ONLY_ETH_WEI); + await settleOnMain(env, { + ethWei: FEE_ONLY_ETH_WEI, + expectToken: true, + }); + await goToConfirm(env.page, { + token: STUB_TOKEN.address, + balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, + amount: TOKEN_AMOUNT, + }); + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + console.log( + "# erc-20 gate probe: ethBalance=" + + formatEther(FEE_ONLY_ETH_WEI) + + " estimate=" + + feeEth(FEE_ESTIMATE_WEI) + + " reserve=" + + feeEth(FEE_RESERVE_WEI) + + " sendDisabled=" + + st.sendDisabled + + " gasError=" + + st.gasError, + ); + assert( + st.sendDisabled, + "Send is ENABLED for an ERC-20 transfer whose fee RESERVE exceeds the " + + "ETH balance — the spend gate is reading the displayed estimate (#154)", + ); + assert( + st.gasError, + "the ERC-20 network-fee message is not shown when the ETH balance cannot cover the reserve", + ); + assert( + !st.amountFeeError, + "the native-ETH over-budget message is shown on an ERC-20 transfer", + ); + assert( + st.errors === "", + "a token-balance message is shown for a transfer the token balance covers: " + + JSON.stringify(st.errors), + ); + assert( + st.height === env.erc20PendingHeight, + "the ERC-20 fee-error state is a different height than its pending state: " + + env.erc20PendingHeight + + "px -> " + + st.height + + "px", + ); +}); + +test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee problem (#238)", async (env) => { + env.errors.expect( + "confirmTx logging the failed ERC-20 gas estimate", + /gas estimation failed/, + ); + env.routeOpts.failGasEstimate = true; + await goToConfirm(env.page, { + token: STUB_TOKEN.address, + balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, + amount: TOKEN_AMOUNT, + }); + await waitForEstimate(env.page); + + const st = await confirmState(env.page); + env.routeOpts.failGasEstimate = false; + assert( + st.fee === "Unable to estimate", + "the ERC-20 fee line does not report the failure: " + + JSON.stringify(st.fee), + ); + assert( + st.feeUnknownError, + "the estimate-failed message is not shown on the ERC-20 path", + ); + assert( + !st.gasError, + "the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable", + ); + assert( + st.sendDisabled, + "Send is enabled on the ERC-20 path with no usable fee estimate", + ); + assert( + st.height === env.erc20PendingHeight, + "the ERC-20 estimate-failed state is a different height than its pending state: " + + env.erc20PendingHeight + + "px -> " + + st.height + + "px", + ); +}); + // ---------------------------------------------------------------- runner async function main() { @@ -508,7 +1082,15 @@ async function main() { return; } - const routeOpts = { seedTokenTransfer: false }; + // Every fixture switch the suite can flip, declared in one place so the + // starting state of a run is readable without hunting through tests. + const routeOpts = { + seedTokenTransfer: false, + seedTokenBalance: false, + ethBalanceWei: null, + failGasEstimate: false, + holdGasEstimate: false, + }; let session; try { @@ -530,9 +1112,16 @@ async function main() { popupUrl: session.popupUrl, routeOpts, page: null, + // The error collector, so a test that drives a failure path on + // purpose can declare the console.error it is about to provoke. + errors: session.errors, // The recovery phrase of the wallet created in test 2, so later // tests can assert on the real secret rather than its shape. phrase: null, + // Confirmation-screen heights, measured in the pending state and + // compared against every later state of the same screen. + ethPendingHeight: null, + erc20PendingHeight: null, }; // Attribution of collected errors is total. session.errors has no @@ -579,6 +1168,16 @@ async function main() { failure = "uncaught browser errors during this test"; } + // A test that declared an error it meant to provoke and did not + // provoke it asserted nothing. Failing here is what keeps expect() + // from being usable as a mute. + const unmatched = session.errors.unmatchedExpectations(); + if (!failure && unmatched.length > 0) { + failure = + "expected browser error(s) that never arrived: " + + unmatched.join("; "); + } + if (failure) { failed += 1; console.log("not ok " + n + " - " + t.name);