Compare commits

...

1 Commits

Author SHA1 Message Date
d74dd40743 fix: WaitTx timeout no longer overwrites a rendered success screen (closes #155)
All checks were successful
check / check (push) Successful in 29s
A poll tick that found a receipt called showSuccess() and then fell through
to the elapsed check, so on the tick crossing the 60-second deadline the
"Transaction Confirmed" screen was immediately replaced by "not confirmed
within 60 seconds" — the user is told a confirmed transaction failed.

The wait now has an explicit lifecycle. A wait id is bumped by endWait(),
which is called on receipt, on timeout, when a new wait starts and when the
user navigates away; every timer callback and every post-await continuation
checks it, so exactly one outcome can be rendered per wait and no stale
timer or in-flight receipt lookup can touch a view it no longer owns.

A receipt lookup that throws is treated as "no answer this tick" rather than
"no receipt": the poll returns before the deadline check and keeps running,
so one transient RPC failure cannot declare a timeout. This matters most on
a resumed wait, whose first poll is immediate and may already be past the
deadline, where a single error would otherwise be terminal. Retrying is
bounded: six consecutive failed lookups — 60 seconds at the poll cadence,
the same patience the confirmation deadline gets — end the wait and report
that the network could not be reached, pointing at the RPC URL in Settings.
That is a different fact from the timeout, because the chain was never
asked, and it says so rather than claiming the transaction did not confirm.
Any lookup that answers, with a receipt or with null, resets the count. An
unbounded retry would be worse than the bug it avoids: the wait is persisted,
so a mistyped RPC URL would leave a wait that every popup open resumes and
nothing ever ends, on a view with no exit control of its own.

The wait is also persisted (state.viewData.pendingWait) and "wait-tx" is now
restorable: reopening the popup resumes the poll with the elapsed counter
and the deadline still measured from the original broadcast, instead of
silently abandoning the wait. restoreWait() validates every field startWait()
goes on to use, not just the presence of the containers — hash, a non-array
object txInfo carrying a string to and a string amount, and a finite numeric
broadcastTime — and returns false otherwise. txInfo.to reaches addressTitle(),
which calls address.toLowerCase(), so a payload merely missing that one field
would throw a TypeError out of restoreView(), which init() does not guard:
the rest of popup init is skipped and wait-tx stays on screen with no back
control. A non-numeric broadcastTime leaves an unexitable wait counting
"NaNs". Polling stays in the popup rather than moving to the background,
which would depend on setInterval surviving in an MV3 service worker.

"wait-tx" is added to src/popup/restorableViews.js, and a test pins its
membership. restoreView() refuses any view outside that set, so dropping the
entry would kill the resume feature silently — the other tests call
restoreWait() directly and never read the set.

The 60-second threshold and the timeout copy are unchanged.
2026-08-12 08:17:55 +00:00
6 changed files with 643 additions and 20 deletions

View File

@@ -688,10 +688,23 @@ of it.
- 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`)

View File

@@ -44,6 +44,12 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: 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-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds

View File

@@ -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();

View File

@@ -22,6 +22,7 @@ const RESTORABLE_VIEWS = new Set([
"settings-addtoken",
"confirm-tx",
"transaction",
"wait-tx",
"success-tx",
"error-tx",
]);

View File

@@ -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,
};

467
tests/txStatus.test.js Normal file
View File

@@ -0,0 +1,467 @@
// 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, "&lt;")
.replace(/>/g, "&gt;");
},
});
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() },
{
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);
});
});