From afe6ddaea0d32107093701e1511ec53b75120934 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 12 Aug 2026 10:58:36 +0200 Subject: [PATCH] fix: WaitTx timeout no longer overwrites a rendered success screen (closes #155) --- README.md | 17 +- TODO.md | 6 + src/popup/index.js | 6 + src/popup/restorableViews.js | 1 + src/popup/views/txStatus.js | 166 ++++++++++-- tests/txStatus.test.js | 482 +++++++++++++++++++++++++++++++++++ 6 files changed, 658 insertions(+), 20 deletions(-) create mode 100644 tests/txStatus.test.js diff --git a/README.md b/README.md index 7d85024..d6aeafd 100644 --- a/README.md +++ b/README.md @@ -706,10 +706,23 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. - To: color dot + full address + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link - Count-up timer: "Waiting for confirmation... Ns" -- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. +- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is + persisted: closing and reopening the popup resumes the poll, with the elapsed + counter and the timeout deadline still measured from the original broadcast. A + lookup that fails is retried on the next tick rather than counted as a missing + receipt, because a failed lookup says nothing about the transaction; but six + failures in a row (60 seconds at the poll cadence) end the wait, so an RPC + that never answers cannot leave it running indefinitely. Any lookup that + answers resets that count. - **Transitions**: - Receipt found → **SuccessTx** - - 60 seconds without confirmation → **ErrorTx** (timeout message) + - A lookup that answers "no receipt" 60 seconds or more after broadcast → + **ErrorTx** (timeout message) + - Six consecutive failed lookups → **ErrorTx**, with a message naming the + unreachable network and pointing at the RPC URL in Settings. This is a + different fact from the timeout — the chain was never asked — and says so + - Exactly one outcome: a receipt found on the tick that crosses the deadline + wins, and no outcome can be rendered over another #### SuccessTx (`success-tx`) diff --git a/TODO.md b/TODO.md index ea664d4..50a2de8 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,12 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no + longer both render on one tick, no timer or in-flight lookup outlives its + wait, a failed receipt lookup no longer counts as a timeout (but six in a row + end the wait, reported as an unreachable network rather than as a timeout), + and the wait now resumes after a popup close + ([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)). - 2026-08-12: The private key export screen now wipes the key from the page whenever it is left by any route, and a decrypt still in flight when the screen is left is discarded instead of written; the same `onViewLeave()` diff --git a/src/popup/index.js b/src/popup/index.js index d06244e..ed4e955 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -165,6 +165,12 @@ function restoreView() { fallbackView(); } break; + case "wait-tx": + // Resumes the receipt poll from the persisted broadcast time. + if (!txStatus.restoreWait()) { + fallbackView(); + } + break; case "success-tx": if (state.viewData && state.viewData.hash) { txStatus.renderSuccess(); diff --git a/src/popup/restorableViews.js b/src/popup/restorableViews.js index eb9b077..9d20495 100644 --- a/src/popup/restorableViews.js +++ b/src/popup/restorableViews.js @@ -22,6 +22,7 @@ const RESTORABLE_VIEWS = new Set([ "settings-addtoken", "confirm-tx", "transaction", + "wait-tx", "success-tx", "error-tx", ]); diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js index d45a67d..3a114da 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -16,11 +16,36 @@ const { state, saveState, currentNetwork } = require("../../shared/state"); const { getProvider } = require("../../shared/balances"); const { log } = require("../../shared/log"); +// Receipt poll cadence and the deadline after which the wait is reported as +// a timeout. Both are documented in the WaitTx section of README.md. +const POLL_INTERVAL_MS = 10000; +const TIMEOUT_MS = 60000; + +// How many receipt lookups may fail in a row before the wait is ended and +// the failure reported. A lookup that throws says nothing about the +// transaction, so one must not end the wait — but an RPC that never answers +// (a mistyped URL in settings is the ordinary case) must not leave the wait +// running forever either, least of all a persisted one that every popup +// open would resume. Six is 60 seconds at the poll cadence: the same +// patience the confirmation deadline gets. Any lookup that answers, with a +// receipt or with null, resets the count. +const MAX_CONSECUTIVE_LOOKUP_FAILURES = 6; + let ctx; let elapsedTimer = null; let pollTimer = null; -function clearTimers() { +// Identifies the wait currently on screen. Bumped by endWait(), so a timer +// callback or an in-flight receipt lookup that outlives its wait can tell +// that it is stale and leave the current view alone. Without it, a receipt +// resolving after the wait has ended renders over whatever view replaced it. +let waitId = 0; + +// End the wait on screen: stop its timers and invalidate its pending async +// work. Called on receipt, on timeout, when a new wait starts, and when the +// user navigates away. +function endWait() { + waitId++; if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; @@ -47,8 +72,13 @@ function blockNumberHtml(blockNumber) { return copyableHtml(num) + etherscanLinkHtml(link); } -function showWait(txInfo, txHash) { - clearTimers(); +// Render the wait view and start polling for the receipt. broadcastTime is +// when the transaction was broadcast, which is what the elapsed counter and +// the timeout deadline are both measured from; pollNow runs one lookup +// immediately instead of waiting a full poll interval. +function startWait(txInfo, txHash, broadcastTime, pollNow) { + endWait(); + const id = waitId; const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; $("wait-tx-summary").textContent = txInfo.amount + " " + symbol; @@ -56,41 +86,130 @@ function showWait(txInfo, txHash) { $("wait-tx-hash").innerHTML = txHashHtml(txHash); attachCopyHandlers("view-wait-tx"); - const broadcastTime = Date.now(); - $("wait-tx-status").textContent = "Waiting for confirmation... 0s"; + // Persisted so closing and reopening the popup resumes this wait + // instead of silently abandoning it. + state.viewData = { + pendingWait: { + txInfo: txInfo, + hash: txHash, + broadcastTime: broadcastTime, + }, + }; - elapsedTimer = setInterval(() => { + function renderElapsed() { const elapsed = Math.floor((Date.now() - broadcastTime) / 1000); $("wait-tx-status").textContent = "Waiting for confirmation... " + elapsed + "s"; + } + renderElapsed(); + + elapsedTimer = setInterval(() => { + if (id !== waitId) return; + renderElapsed(); }, 1000); const provider = getProvider(state.rpcUrl); - pollTimer = setInterval(async () => { + let consecutiveFailures = 0; + + async function poll() { + if (id !== waitId) return; + let receipt = null; + let answered = true; try { - const receipt = await provider.getTransactionReceipt(txHash); - if (receipt) { - showSuccess(txInfo, txHash, receipt.blockNumber); - } + receipt = await provider.getTransactionReceipt(txHash); } catch (e) { + // A thrown lookup means "no answer this tick", not "no + // receipt": the RPC failed, the chain said nothing. Declaring + // the timeout off it would report a confirmed transaction as + // failed — which matters most on a resumed wait, where the + // first poll is already past the deadline. + answered = false; log.errorf("poll receipt failed:", e.message); } - - const elapsed = Math.floor((Date.now() - broadcastTime) / 1000); - if (elapsed >= 60) { + // The lookup is async: the wait may have ended while it was in + // flight, in which case this result must not touch the view. + if (id !== waitId) return; + // Exactly one outcome per wait. A receipt wins even on the tick + // that crosses the deadline, because the transaction did confirm. + if (receipt) { + showSuccess(txInfo, txHash, receipt.blockNumber); + return; + } + if (!answered) { + consecutiveFailures++; + // The failure is the user's news, and it is a different fact + // from "the transaction did not confirm" — the chain was never + // asked. Ending the wait here is what keeps it bounded and + // gives the user a Done button to leave by. + if (consecutiveFailures >= MAX_CONSECUTIVE_LOOKUP_FAILURES) { + showError( + txInfo, + txHash, + "The network could not be reached to check this transaction — " + + MAX_CONSECUTIVE_LOOKUP_FAILURES + + " lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.", + ); + } + // Otherwise keep polling: the next tick may answer. + return; + } + consecutiveFailures = 0; + if (Date.now() - broadcastTime >= TIMEOUT_MS) { showError( txInfo, txHash, "Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.", ); } - }, 10000); + } + + pollTimer = setInterval(poll, POLL_INTERVAL_MS); showView("wait-tx"); + + if (pollNow) poll(); +} + +function showWait(txInfo, txHash) { + startWait(txInfo, txHash, Date.now(), false); +} + +// Resume a wait persisted by a previous popup session. The deadline still +// runs from the original broadcast, so a wait that has already outlived it +// resolves on the immediate first poll rather than restarting the clock. +// Returns false when there is nothing resumable to resume. Every field +// startWait() goes on to use is validated, not just the presence of the +// containers: txInfo.to reaches addressTitle(), which calls +// address.toLowerCase(), and txInfo.amount is rendered into the summary, so +// an object merely missing one of them throws a TypeError out of +// restoreView() — which init() does not guard, skipping the rest of popup +// init and leaving wait-tx on screen with no back control. A non-numeric +// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and +// txInfo.tokenSymbol are deliberately unchecked: they are compared and +// coalesced rather than dereferenced, and tokenSymbol is null for ETH. +function restoreWait() { + const d = state.viewData; + if (!d || !d.pendingWait) return false; + const w = d.pendingWait; + if (!w.hash) return false; + // typeof [] is "object", so an array passes an object check. + const info = w.txInfo; + if (!info || typeof info !== "object" || Array.isArray(info)) return false; + // A string is the whole requirement: the empty string is what a + // contract-deployment approval persists (approval.js writes `to: toAddr + // || ""`), and both fields render harmlessly when empty, so refusing it + // would abandon a wait the live path itself created. + if (typeof info.to !== "string") return false; + if (typeof info.amount !== "string") return false; + if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) { + return false; + } + startWait(w.txInfo, w.hash, w.broadcastTime, true); + return true; } function showSuccess(txInfo, txHash, blockNumber) { - clearTimers(); + endWait(); const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; state.viewData = { @@ -182,7 +301,7 @@ function renderSuccess() { } function showError(txInfo, txHash, message) { - clearTimers(); + endWait(); const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; state.viewData = { @@ -218,6 +337,9 @@ function isApprovalPopup() { } function navigateBack() { + // Nothing should still be polling by now, but leaving a view is the + // point at which its timers must be gone. + endWait(); if (isApprovalPopup()) { window.close(); return; @@ -242,4 +364,12 @@ function init(_ctx) { $("btn-error-tx-done").addEventListener("click", navigateBack); } -module.exports = { init, showWait, showError, renderSuccess, renderError }; +module.exports = { + init, + showWait, + restoreWait, + endWait, + showError, + renderSuccess, + renderError, +}; diff --git a/tests/txStatus.test.js b/tests/txStatus.test.js new file mode 100644 index 0000000..5232bcc --- /dev/null +++ b/tests/txStatus.test.js @@ -0,0 +1,482 @@ +// Lifecycle tests for the post-broadcast transaction status views +// (src/popup/views/txStatus.js). +// +// The bug these pin down: the receipt poll rendered both outcomes on the tick +// that crossed the 60-second deadline, so a confirmed transaction was replaced +// by "not confirmed within 60 seconds" — the user is told their transaction +// failed when it succeeded. The same shape applies to any callback that +// outlives its wait: a receipt lookup still in flight when the view is left +// must not render over whatever replaced it. +// +// Fake timers make the race deterministic: the receipt promise is already +// resolved when the deadline tick runs, so in the unfixed code showSuccess() +// is always followed by showError() on that tick. +// +// No network: getProvider is mocked at the module boundary and there is no +// jsdom in this repo, so the handful of DOM calls these views make are served +// by the stub below. + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: jest.fn(), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +const mockReceiptLookup = jest.fn(); +jest.mock("../src/shared/balances", () => ({ + getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }), + refreshBalances: jest.fn(), +})); + +global.fetch = jest.fn(() => { + throw new Error("tests must not perform network requests"); +}); + +// --------------------------------------------------------------------------- +// Minimal DOM. Every element is created on demand and remembered by id, so a +// test can read back what a view wrote into it. +// --------------------------------------------------------------------------- + +const elements = new Map(); + +function makeElement(id) { + const classes = new Set(["view", "hidden"]); + const el = { + id, + textContent: "", + innerHTML: "", + style: {}, + classList: { + add: (c) => classes.add(c), + remove: (c) => classes.delete(c), + contains: (c) => classes.has(c), + toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), + }, + addEventListener: () => {}, + querySelectorAll: () => [], + remove: () => {}, + prepend: () => {}, + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => getElement(id + "-parent"), + }); + return el; +} + +function getElement(id) { + if (!elements.has(id)) elements.set(id, makeElement(id)); + return elements.get(id); +} + +global.document = { + getElementById: (id) => getElement(id), + // escapeHtml() builds a detached div; textContent in, escaped HTML out. + createElement: () => { + const el = { innerHTML: "" }; + Object.defineProperty(el, "textContent", { + set(v) { + el.innerHTML = String(v) + .replace(/&/g, "&") + .replace(//g, ">"); + }, + }); + return el; + }, + body: { prepend: () => {} }, + addEventListener: () => {}, +}; + +global.window = { location: { search: "" } }; + +const stored = {}; +global.chrome = { + storage: { + local: { + set: (obj) => { + Object.assign(stored, obj); + return Promise.resolve(); + }, + get: () => Promise.resolve(stored), + }, + }, +}; + +const txStatus = require("../src/popup/views/txStatus"); +const { state } = require("../src/shared/state"); +const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); + +const TX_HASH = + "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84"; +const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const TX_INFO = { + to: RECIPIENT, + amount: "0.0050", + token: "ETH", + tokenSymbol: null, +}; + +// True when a view element is not hidden. +function visible(view) { + return !getElement("view-" + view).classList.contains("hidden"); +} + +function waitStatusText() { + return getElement("wait-tx-status").textContent; +} + +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-08-11T12:00:00Z")); + elements.clear(); + mockReceiptLookup.mockReset(); + state.wallets = []; + state.viewData = {}; + state.viewStack = []; + state.currentView = null; + txStatus.init({ doRefreshAndRender: jest.fn() }); +}); + +afterEach(() => { + txStatus.endWait(); + jest.useRealTimers(); +}); + +describe("WaitTx receipt/timeout race", () => { + test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => { + // No receipt for the first five polls; the sixth — the tick at + // t=60s, which is also the timeout deadline — returns one. + mockReceiptLookup + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue({ blockNumber: 21000000 }); + + txStatus.showWait(TX_INFO, TX_HASH); + expect(visible("wait-tx")).toBe(true); + + await jest.advanceTimersByTimeAsync(60000); + + expect(visible("success-tx")).toBe(true); + expect(visible("error-tx")).toBe(false); + expect(state.currentView).toBe("success-tx"); + expect(state.viewData.blockNumber).toBe(21000000); + expect(state.viewData.message).toBeUndefined(); + + // And nothing is left running to undo it. + expect(jest.getTimerCount()).toBe(0); + await jest.advanceTimersByTimeAsync(300000); + expect(state.currentView).toBe("success-tx"); + expect(mockReceiptLookup).toHaveBeenCalledTimes(6); + }); + + test("a genuine timeout still shows ErrorTx with the hash", async () => { + mockReceiptLookup.mockResolvedValue(null); + + txStatus.showWait(TX_INFO, TX_HASH); + await jest.advanceTimersByTimeAsync(60000); + + expect(visible("error-tx")).toBe(true); + expect(state.currentView).toBe("error-tx"); + expect(state.viewData.message).toMatch( + /not confirmed within 60 seconds/, + ); + expect(state.viewData.hash).toBe(TX_HASH); + // The hash section carries the hash and the etherscan link. + expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH); + expect(getElement("error-tx-hash").innerHTML).toContain( + "/tx/" + TX_HASH, + ); + expect(jest.getTimerCount()).toBe(0); + }); + + test("a receipt still in flight when the view is left does not render over it", async () => { + let resolveReceipt; + mockReceiptLookup.mockReturnValue( + new Promise((r) => { + resolveReceipt = r; + }), + ); + + txStatus.showWait(TX_INFO, TX_HASH); + await jest.advanceTimersByTimeAsync(10000); + expect(mockReceiptLookup).toHaveBeenCalledTimes(1); + + // User leaves the wait (popup navigation / teardown) while the + // lookup is outstanding, then the lookup finally answers. + txStatus.endWait(); + state.currentView = "main"; + resolveReceipt({ blockNumber: 21000000 }); + await Promise.resolve(); + await Promise.resolve(); + + expect(state.currentView).toBe("main"); + expect(visible("success-tx")).toBe(false); + }); + + test("no timer survives the view being left", async () => { + mockReceiptLookup.mockResolvedValue(null); + + txStatus.showWait(TX_INFO, TX_HASH); + expect(jest.getTimerCount()).toBeGreaterThan(0); + + txStatus.endWait(); + expect(jest.getTimerCount()).toBe(0); + + await jest.advanceTimersByTimeAsync(120000); + expect(mockReceiptLookup).not.toHaveBeenCalled(); + }); +}); + +describe("WaitTx persistence across popup close", () => { + test("restoreWait resumes the poll with the deadline running from broadcast", async () => { + mockReceiptLookup.mockResolvedValue(null); + + txStatus.showWait(TX_INFO, TX_HASH); + expect(state.viewData.pendingWait.hash).toBe(TX_HASH); + const persisted = JSON.parse(JSON.stringify(state.viewData)); + + // Popup closes: timers die with the page. + txStatus.endWait(); + + // 45 seconds pass with the popup shut, then it is reopened. + jest.advanceTimersByTime(45000); + state.viewData = persisted; + expect(txStatus.restoreWait()).toBe(true); + + expect(visible("wait-tx")).toBe(true); + // Elapsed is counted from the broadcast, not from the reopen. + expect(waitStatusText()).toBe("Waiting for confirmation... 45s"); + // The immediate poll on resume has already run. + await Promise.resolve(); + expect(mockReceiptLookup).toHaveBeenCalledTimes(1); + + // The deadline is 15 seconds away, not 60. + await jest.advanceTimersByTimeAsync(20000); + expect(state.currentView).toBe("error-tx"); + }); + + test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => { + // A wait resumed after the deadline has already passed: the first + // poll is immediate and past 60s, so a thrown lookup must not be + // read as "no receipt". It means "no answer this tick" — keep + // polling, because the transaction may well have confirmed. + mockReceiptLookup.mockResolvedValue(null); + txStatus.showWait(TX_INFO, TX_HASH); + const persisted = JSON.parse(JSON.stringify(state.viewData)); + txStatus.endWait(); + + // Ten minutes with the popup shut, then it is reopened and the + // first receipt lookup fails transiently. + jest.advanceTimersByTime(600000); + mockReceiptLookup.mockReset(); + mockReceiptLookup + .mockRejectedValueOnce(new Error("rpc unavailable")) + .mockResolvedValue({ blockNumber: 21000000 }); + + state.viewData = persisted; + expect(txStatus.restoreWait()).toBe(true); + await jest.advanceTimersByTimeAsync(0); + + // The wait is still alive: no timeout was declared off one error. + expect(visible("wait-tx")).toBe(true); + expect(visible("error-tx")).toBe(false); + expect(state.currentView).toBe("wait-tx"); + expect(jest.getTimerCount()).toBeGreaterThan(0); + + // And the next tick answers, so the confirmed transaction is + // reported as confirmed. + await jest.advanceTimersByTimeAsync(10000); + expect(state.currentView).toBe("success-tx"); + expect(state.viewData.blockNumber).toBe(21000000); + }); + + test("a lookup returning null past the deadline still times out", async () => { + // The counterpart to the test above: the deadline must still fire + // when the lookup actually answers "no receipt". + mockReceiptLookup.mockResolvedValue(null); + txStatus.showWait(TX_INFO, TX_HASH); + const persisted = JSON.parse(JSON.stringify(state.viewData)); + txStatus.endWait(); + + jest.advanceTimersByTime(600000); + state.viewData = persisted; + expect(txStatus.restoreWait()).toBe(true); + await jest.advanceTimersByTimeAsync(0); + + expect(state.currentView).toBe("error-tx"); + expect(state.viewData.message).toMatch( + /not confirmed within 60 seconds/, + ); + }); + + test("restoreWait reports nothing to resume when no wait is persisted", () => { + state.viewData = {}; + expect(txStatus.restoreWait()).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + + test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => { + for (const bad of [ + { hash: TX_HASH, broadcastTime: Date.now() }, + { hash: TX_HASH, txInfo: TX_INFO }, + { hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" }, + { hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN }, + { hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() }, + // An object that merely lacks a field startWait() dereferences + // is the shape that actually escaped: txInfo.to reaches + // addressTitle(), which calls address.toLowerCase(). typeof [] + // is "object", so an array passes an object check. + { hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() }, + { hash: TX_HASH, txInfo: [], broadcastTime: Date.now() }, + { hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() }, + // Otherwise complete but for a non-string `to`: only the `to` + // check rejects this one, and without it addressTitle() throws + // out of restoreView(). + { + hash: TX_HASH, + txInfo: { to: 42, amount: "0.0050" }, + broadcastTime: Date.now(), + }, + // Otherwise complete but an array: only Array.isArray() rejects + // it, since typeof [] is "object" and the fields are present. + { + hash: TX_HASH, + txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }), + broadcastTime: Date.now(), + }, + { + hash: TX_HASH, + txInfo: { to: RECIPIENT }, + broadcastTime: Date.now(), + }, + ]) { + state.viewData = { pendingWait: bad }; + expect(txStatus.restoreWait()).toBe(false); + expect(jest.getTimerCount()).toBe(0); + } + }); + + test("restoreWait resumes a wait whose recipient is the empty string", () => { + // The shape a contract-deployment approval persists: approval.js + // writes `to: toAddr || ""`, and showWait() renders it without + // complaint. Validation must not be stricter than the live path, or + // that wait is silently abandoned on every popup open. + mockReceiptLookup.mockResolvedValue(null); + state.viewData = { + pendingWait: { + hash: TX_HASH, + txInfo: { ...TX_INFO, to: "" }, + broadcastTime: Date.now(), + }, + }; + expect(txStatus.restoreWait()).toBe(true); + expect(visible("wait-tx")).toBe(true); + }); +}); + +describe("WaitTx against an RPC that never answers", () => { + test("a permanently failing lookup ends the wait instead of polling forever", async () => { + mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable")); + + txStatus.showWait(TX_INFO, TX_HASH); + + // Six consecutive failures is 60 seconds at the 10s cadence — the + // same patience as the confirmation deadline. + await jest.advanceTimersByTimeAsync(60000); + + expect(state.currentView).toBe("error-tx"); + expect(visible("wait-tx")).toBe(false); + // The user is told what actually happened: the lookup failed. It is + // not the same fact as "the transaction did not confirm". + expect(state.viewData.message).toMatch(/could not be reached/i); + expect(state.viewData.message).not.toMatch(/not confirmed within/); + expect(state.viewData.hash).toBe(TX_HASH); + + // Nothing is left running, and nothing is left to resume onto. + expect(jest.getTimerCount()).toBe(0); + expect(state.viewData.pendingWait).toBeUndefined(); + + const calls = mockReceiptLookup.mock.calls.length; + await jest.advanceTimersByTimeAsync(3600000); + expect(mockReceiptLookup).toHaveBeenCalledTimes(calls); + expect(state.currentView).toBe("error-tx"); + }); + + test("an answered lookup clears the failure count, so the bound is on consecutive failures", async () => { + // The bound counts failures in a row, not failures in total: a + // flaky RPC that keeps answering in between must not accumulate its + // way to a false "network unreachable". + // + // Polls 1-5 (t=10s..50s) alternate reject / null, so three fail and + // the last answer resets the count at poll 4. From poll 6 on every + // lookup fails. Six in a row is then poll 10, at t=100s. A counter + // that never reset would have reached six at poll 8, t=80s, so the + // window between those two is what this test occupies. + mockReceiptLookup.mockImplementation(() => { + const n = mockReceiptLookup.mock.calls.length; + if (n <= 5 && n % 2 === 0) return Promise.resolve(null); + return Promise.reject(new Error("flaky")); + }); + + txStatus.showWait(TX_INFO, TX_HASH); + + // t=90s: eight failures in total, five of them in a row. A + // cumulative counter has long since fired; a consecutive one has not. + await jest.advanceTimersByTimeAsync(90000); + expect(state.currentView).toBe("wait-tx"); + expect(visible("wait-tx")).toBe(true); + expect(jest.getTimerCount()).toBeGreaterThan(0); + + // t=100s: the sixth in a row. + await jest.advanceTimersByTimeAsync(10000); + expect(state.currentView).toBe("error-tx"); + expect(state.viewData.message).toMatch(/could not be reached/i); + // No lookup ever answered "no receipt" past the deadline, so this + // is not the timeout and must not be reported as one. + expect(state.viewData.message).not.toMatch(/not confirmed within/); + expect(jest.getTimerCount()).toBe(0); + }); + + test("a resumed wait against a dead RPC also terminates", async () => { + // The reopen path is the one that made this unbounded: the wait is + // persisted, so without a bound every popup open resumes it forever. + mockReceiptLookup.mockResolvedValue(null); + txStatus.showWait(TX_INFO, TX_HASH); + const persisted = JSON.parse(JSON.stringify(state.viewData)); + txStatus.endWait(); + + jest.advanceTimersByTime(3600000); + mockReceiptLookup.mockReset(); + mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable")); + + state.viewData = persisted; + expect(txStatus.restoreWait()).toBe(true); + await jest.advanceTimersByTimeAsync(60000); + + expect(state.currentView).toBe("error-tx"); + expect(state.viewData.message).toMatch(/could not be reached/i); + expect(jest.getTimerCount()).toBe(0); + expect(state.viewData.pendingWait).toBeUndefined(); + }); +}); + +describe("wait-tx is a view the popup may reopen onto", () => { + // The resume feature is wired through RESTORABLE_VIEWS: restoreView() + // refuses any view not in the set, so dropping "wait-tx" from it kills + // the resume silently — the tests above call restoreWait() directly and + // would all still pass. This pins the membership. Mirrors the exclusion + // assertions in tests/showPhrase.test.js. + test("wait-tx is restorable", () => { + expect(RESTORABLE_VIEWS.has("wait-tx")).toBe(true); + }); +});