Compare commits

...

3 Commits

Author SHA1 Message Date
clawbot
570441cf5b fix: WaitTx timeout no longer overwrites a rendered success screen (closes #155)
Some checks failed
check / check (push) Has been cancelled
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.

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 the whole persisted
payload — hash, txInfo and a finite numeric broadcastTime — and returns
false otherwise, so a malformed payload falls back to the main view instead
of throwing out of restoreView() or rendering an unexitable "NaNs" wait.
Polling stays in the popup rather than moving to the background, which would
depend on setInterval surviving in an MV3 service worker.

The 60-second threshold and the timeout copy are unchanged.
2026-08-11 12:52:58 +00:00
9b957ffd69 fix: derive hasWallet from the wallet list on load (closes #195)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:51:22 +02:00
cf5f582be9 docs: correct three README claims contradicted by the code (closes #213)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:41:16 +02:00
7 changed files with 620 additions and 38 deletions

View File

@@ -346,17 +346,32 @@ The core hierarchy is **Wallets → Addresses**:
address scan on import, but imported from an extended private key rather address scan on import, but imported from an extended private key rather
than a recovery phrase. It therefore has no recovery phrase to display or than a recovery phrase. It therefore has no recovery phrase to display or
back up. back up.
- An **address** holds ETH and any user-added ERC-20 tokens. - An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a - The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key). single address (key).
Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled top-250 token list, is tracked by
the user, or has 1,000 or more holders; a token claiming a symbol from the
bundled list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
with zero balance" is on.
#### Navigation #### Navigation
The main view shows all addresses grouped by wallet, with ETH balances inline. The main view shows all addresses grouped by wallet, with ETH balances inline.
The user taps an address to see its detail view (full address, balance, tokens, The user taps an address to see its detail view (full address, balance, tokens,
send/receive). Navigation is flat — every view has a "Back" or "Cancel" button send/receive). Navigation is a stack: each forward action pushes the current
that returns to the previous context. No deep nesting, no tabs, no hamburger screen, and every view has a "Back" or "Cancel" button that pops back to it (see
menus. the Screen Map below). There is no hamburger menu and no persistent tab bar; the
Settings gear in the title bar is the only global control. Two screens carry an
in-screen control beyond that: AddWallet uses three tabs to select the import
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
behind a "···" menu.
### Screen Map ### Screen Map
@@ -393,7 +408,7 @@ screen, including ExportPrivKey, falls back to Home.
- **When**: At least one wallet exists. This is the root screen. - **When**: At least one wallet exists. This is the root screen.
- **Elements**: - **Elements**:
- Active address ETH balance (large) + USD value in parentheses - Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and all tracked tokens of the active address - "Total:" USD value across ETH and every token shown for the active address
- Active address (color dot, full address, etherscan link, tap to copy) - Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons, both acting on the active address - Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display - ETH/USD price display
@@ -401,7 +416,7 @@ screen, including ExportPrivKey, falls back to Home.
button for HD and xprv wallets, then one block per address with "Address button for HD and xprv wallets, then one block per address with "Address
N" (bold when active), the ENS name if resolved, the full address, an N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, the address USD total, and a balance line for ETH and for `[info]` button, the address USD total, and a balance line for ETH and for
each tracked token each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address - "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered of every wallet, deduplicated by hash and filtered
- "Add additional wallet..." link at bottom - "Add additional wallet..." link at bottom
@@ -454,8 +469,8 @@ screen, including ExportPrivKey, falls back to Home.
- ENS name (if resolved, bold above the address) - ENS name (if resolved, bold above the address)
- Full address (color dot, etherscan link, tap to copy) - Full address (color dot, etherscan link, tap to copy)
- USD total for address - USD total for address
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline). - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
Each balance row is clickable → **AddressToken** places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons and a "···" menu button - Send / Receive / + Token buttons and a "···" menu button
- "···" dropdown containing a single "Export Private Key" entry - "···" dropdown containing a single "Export Private Key" entry
- Transaction list (with ENS resolution for counterparties) - Transaction list (with ENS resolution for counterparties)
@@ -570,10 +585,17 @@ screen, including ExportPrivKey, falls back to Home.
- 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.
- **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)
- Exactly one of the two: a receipt found on the tick that crosses the
deadline wins, and neither outcome can be rendered over the other
#### SuccessTx (`success-tx`) #### SuccessTx (`success-tx`)
@@ -861,7 +883,7 @@ communicates with three external services to function as a wallet:
What the extension does NOT do: What the extension does NOT do:
- No analytics or telemetry services - No analytics or telemetry services
- No token list APIs (user adds tokens manually by contract address) - No token list APIs (the top-250 token list is bundled at build time)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
@@ -984,7 +1006,8 @@ hardcoded test phrase.
- Add multiple addresses within an HD wallet - Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously - Manage multiple wallets simultaneously
- View ETH balance per address - View ETH balance per address
- View ERC-20 token balances (user adds token by contract address) - View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
holders, and tokens the user adds by contract address)
- Send ETH to an address - Send ETH to an address
- Send ERC-20 tokens to an address - Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code) - Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -1130,7 +1153,8 @@ Currently supported:
- Built in token swaps (use a DEX in the browser) - Built in token swaps (use a DEX in the browser)
- Analytics, telemetry, or tracking of any kind - Analytics, telemetry, or tracking of any kind
- Advertisements or promotions - Advertisements or promotions
- Obscure token list auto-discovery (user adds tokens manually) - Obscure token list auto-discovery — nothing outside the bundled list, the
1,000-holder floor, and the tokens the user added by contract address
- We detect common/popular ERC20s in the basic case - We detect common/popular ERC20s in the basic case
- Fiat on/off ramps - Fiat on/off ramps
- Extensive transaction decoding/parsing - Extensive transaction decoding/parsing
@@ -1187,14 +1211,18 @@ This repository includes data files from third-party projects that are not
covered by the GPL-3.0 license above. These files, their copyright holders, and covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are: their licenses are:
| File | Source | Copyright | License | | File | Source | Copyright | License |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- | | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) | | `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT | | `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT | | `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file. [LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation; the vendored copy and
the runtime refresh both come from that upstream, whose URL is the
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
## Author ## Author

12
TODO.md
View File

@@ -44,12 +44,24 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element - 2026-08-11: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/` and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)). ([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names, - 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)). sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 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, and the wait now
resumes after a popup close
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the - 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites active-address change is broadcast to connected sites

View File

@@ -110,6 +110,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",
]); ]);
@@ -176,6 +177,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();

View File

@@ -16,11 +16,26 @@ 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;
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 +62,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 +76,98 @@ 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 () => {
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;
}
// Keep polling until a lookup actually answers; the next tick may.
if (!answered) return;
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: the whole
// payload is validated, because startWait() dereferences txInfo and does
// arithmetic on broadcastTime, and a partial one would throw out of
// restoreView() or leave an unexitable wait counting "NaNs".
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
const w = d.pendingWait;
if (!w.hash) return false;
if (!w.txInfo || typeof w.txInfo !== "object") 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 +259,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 +295,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 +322,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,
};

View File

@@ -84,8 +84,11 @@ async function loadState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || []; state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId; state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;

104
tests/state.test.js Normal file
View File

@@ -0,0 +1,104 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {
delete global.chrome;
});
describe("loadState hasWallet reconciliation", () => {
// A profile that deleted its last wallet on a build predating the write
// path fix keeps hasWallet: true forever. It must load as no wallet, which
// is what sends the popup to the welcome view.
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true });
await mod.loadState();
expect(mod.state.wallets).toEqual([]);
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
});
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("absent hasWallet with wallets present loads as having a wallet", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("consistent stored states are preserved", async () => {
const withWallet = loadModuleWith({
hasWallet: true,
wallets: oneWallet(),
});
await withWallet.mod.loadState();
expect(withWallet.mod.state.hasWallet).toBe(true);
const without = loadModuleWith({ hasWallet: false, wallets: [] });
await without.mod.loadState();
expect(without.mod.state.hasWallet).toBe(false);
});
test("empty storage leaves the default no-wallet state", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
expect(mod.state.wallets).toEqual([]);
});
// The correction is derived on every load rather than written back, so a
// load never has a storage side effect.
test("loadState does not write to storage", async () => {
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(set).not.toHaveBeenCalled();
});
// Deriving must not disturb the rest of the load.
test("other persisted fields still load", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
networkId: "sepolia",
theme: "dark",
activeAddress: ADDRESS,
});
await mod.loadState();
expect(mod.state.networkId).toBe("sepolia");
expect(mod.state.theme).toBe("dark");
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});

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

@@ -0,0 +1,340 @@
// 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 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() },
]) {
state.viewData = { pendingWait: bad };
expect(txStatus.restoreWait()).toBe(false);
expect(jest.getTimerCount()).toBe(0);
}
});
});