Five defects traced to one fact: src/background/index.js read and wrote the module-level `state` singleton in src/shared/state.js, which the MV3 service worker never populates and which answered an unpopulated read out of DEFAULT_STATE in silence. Every previous fix added a loadState() before the access, and that is what produced the fifth: a load detaches the objects an in-flight handler is holding. So the reachability goes rather than a sixth call site. The background now has its own storage layer, src/background/state.js: getState() is a detached, normalized per-call read, and updateState() is a queued read-modify-write whose read is one storage round trip ahead of its write. Nothing in the background holds an in-memory copy of the profile. - Every handler takes one snapshot and answers from it, including the address it names: activeAddressOf(s) replaced a second, later storage read that could disagree with the first. - wallet_switchEthereumChain applies applyChainSwitchFields() (split out of chainSwitch.js, which keeps the singleton path for the popup) inside updateState() instead of calling onChainSwitch() on the singleton. - The remembered site decision is a read-modify-write, not a load-mutate-save around a prompt the user takes seconds to answer. - backgroundRefresh() refreshes a private copy of the wallets and applies the balances that came back by address, so it never publishes an object other in-flight work holds, and a wallet added or deleted during the round trip survives its write. - The transaction attempt takes its chain id and its endpoint from the same snapshot. They used to come from different moments, so a chain switch committed in between moved the endpoint under an artifact already verified against the old chain. getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it against networks.js. That closes the cold-worker wrong-chain send at its shape rather than at one call site: the hint used to default to currentNetwork() off the unpopulated singleton, so the endpoint was the user's chain and ethers fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses() and resolveEnsName() carry the id through; balances.js no longer requires state.js at all. The prohibition is enforced mechanically, not by review: a custom ESLint rule walks the CommonJS require graph from every src/background/ file and fails the lint when src/shared/state.js is reachable, naming the chain. A re-export from any shared module cannot put the singleton back in the bundle unnoticed. Reading a persisted field of the singleton before any load now throws StateNotLoadedError instead of serving DEFAULT_STATE. Test stubs: chrome.storage.local is a serialization boundary, and eight files stubbed it with an aliasing get, so the object a module held and the object "storage" held were one object — an assertion could pass on a build that never wrote anything. They all go through tests/support/storageStub.js now, which structured-clones in both directions. closes #320
479 lines
19 KiB
JavaScript
479 lines
19 KiB
JavaScript
// 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: "" } };
|
|
|
|
// Clones in both directions, as the real chrome.storage.local does; the stub
|
|
// here used to hand back the live stored object, so an in-memory mutation
|
|
// looked like a write that had reached storage. See
|
|
// tests/support/storageStub.js.
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
const storage = makeStorageStub();
|
|
global.chrome = { storage };
|
|
|
|
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);
|
|
});
|
|
});
|