Compare commits
1 Commits
issue-188-
...
e32d6896f2
| Author | SHA1 | Date | |
|---|---|---|---|
| e32d6896f2 |
17
README.md
17
README.md
@@ -689,10 +689,23 @@ of it.
|
|||||||
- To: color dot + full address + etherscan link
|
- To: color dot + full address + etherscan link
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Count-up timer: "Waiting for confirmation... Ns"
|
- 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**:
|
- **Transitions**:
|
||||||
- Receipt found → **SuccessTx**
|
- 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`)
|
#### SuccessTx (`success-tx`)
|
||||||
|
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@@ -44,6 +44,12 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# 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-12: An unreported `holders_count` is now parsed as `null` rather than
|
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
|
||||||
`0`, so the low-holder rule declines to judge an unknown count instead of
|
`0`, so the low-holder rule declines to judge an unknown count instead of
|
||||||
hiding a legitimate token as spam, in both the transaction history and the
|
hiding a legitimate token as spam, in both the transaction history and the
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ function restoreView() {
|
|||||||
fallbackView();
|
fallbackView();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "wait-tx":
|
||||||
|
// Resumes the receipt poll from the persisted broadcast time.
|
||||||
|
if (!txStatus.restoreWait()) {
|
||||||
|
fallbackView();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "success-tx":
|
case "success-tx":
|
||||||
if (state.viewData && state.viewData.hash) {
|
if (state.viewData && state.viewData.hash) {
|
||||||
txStatus.renderSuccess();
|
txStatus.renderSuccess();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const RESTORABLE_VIEWS = new Set([
|
|||||||
"settings-addtoken",
|
"settings-addtoken",
|
||||||
"confirm-tx",
|
"confirm-tx",
|
||||||
"transaction",
|
"transaction",
|
||||||
|
"wait-tx",
|
||||||
"success-tx",
|
"success-tx",
|
||||||
"error-tx",
|
"error-tx",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -16,11 +16,36 @@ const { state, saveState, currentNetwork } = require("../../shared/state");
|
|||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { log } = require("../../shared/log");
|
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 ctx;
|
||||||
let elapsedTimer = null;
|
let elapsedTimer = null;
|
||||||
let pollTimer = 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) {
|
if (elapsedTimer) {
|
||||||
clearInterval(elapsedTimer);
|
clearInterval(elapsedTimer);
|
||||||
elapsedTimer = null;
|
elapsedTimer = null;
|
||||||
@@ -47,8 +72,13 @@ function blockNumberHtml(blockNumber) {
|
|||||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWait(txInfo, txHash) {
|
// Render the wait view and start polling for the receipt. broadcastTime is
|
||||||
clearTimers();
|
// 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 || "?";
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||||
@@ -56,41 +86,130 @@ function showWait(txInfo, txHash) {
|
|||||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||||
attachCopyHandlers("view-wait-tx");
|
attachCopyHandlers("view-wait-tx");
|
||||||
|
|
||||||
const broadcastTime = Date.now();
|
// Persisted so closing and reopening the popup resumes this wait
|
||||||
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
|
// 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);
|
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
||||||
$("wait-tx-status").textContent =
|
$("wait-tx-status").textContent =
|
||||||
"Waiting for confirmation... " + elapsed + "s";
|
"Waiting for confirmation... " + elapsed + "s";
|
||||||
|
}
|
||||||
|
renderElapsed();
|
||||||
|
|
||||||
|
elapsedTimer = setInterval(() => {
|
||||||
|
if (id !== waitId) return;
|
||||||
|
renderElapsed();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const provider = getProvider(state.rpcUrl);
|
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 {
|
try {
|
||||||
const receipt = await provider.getTransactionReceipt(txHash);
|
receipt = await provider.getTransactionReceipt(txHash);
|
||||||
if (receipt) {
|
|
||||||
showSuccess(txInfo, txHash, receipt.blockNumber);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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);
|
log.errorf("poll receipt failed:", e.message);
|
||||||
}
|
}
|
||||||
|
// The lookup is async: the wait may have ended while it was in
|
||||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
// flight, in which case this result must not touch the view.
|
||||||
if (elapsed >= 60) {
|
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(
|
showError(
|
||||||
txInfo,
|
txInfo,
|
||||||
txHash,
|
txHash,
|
||||||
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
|
"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");
|
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) {
|
function showSuccess(txInfo, txHash, blockNumber) {
|
||||||
clearTimers();
|
endWait();
|
||||||
|
|
||||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
@@ -182,7 +301,7 @@ function renderSuccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showError(txInfo, txHash, message) {
|
function showError(txInfo, txHash, message) {
|
||||||
clearTimers();
|
endWait();
|
||||||
|
|
||||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
@@ -218,6 +337,9 @@ function isApprovalPopup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateBack() {
|
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()) {
|
if (isApprovalPopup()) {
|
||||||
window.close();
|
window.close();
|
||||||
return;
|
return;
|
||||||
@@ -242,4 +364,12 @@ function init(_ctx) {
|
|||||||
$("btn-error-tx-done").addEventListener("click", navigateBack);
|
$("btn-error-tx-done").addEventListener("click", navigateBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, showWait, showError, renderSuccess, renderError };
|
module.exports = {
|
||||||
|
init,
|
||||||
|
showWait,
|
||||||
|
restoreWait,
|
||||||
|
endWait,
|
||||||
|
showError,
|
||||||
|
renderSuccess,
|
||||||
|
renderError,
|
||||||
|
};
|
||||||
|
|||||||
482
tests/txStatus.test.js
Normal file
482
tests/txStatus.test.js
Normal file
@@ -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, "<")
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user