Compare commits

..

1 Commits

Author SHA1 Message Date
288c5560de test: containerized Firefox end-to-end harness (closes #184)
All checks were successful
check / check (push) Successful in 46s
Drives the real popup in a real Firefox with dist/firefox/ installed as an
unpacked MV2 temporary add-on, via geckodriver. Covers popup load, wallet
creation through the UI, and the Add Token screen. Outside make check, like
the Chrome suite.

Zero npm dependencies: tests/e2e/firefox/driver.js is a WebDriver client
over global fetch and child_process against geckodriver's HTTP API. The
Dockerfile pins the node base image, the Firefox 153.0.3 tarball and
geckodriver 0.36.0 by digest.

Errors are read from the privileged nsIConsoleService in Marionette's chrome
context, filtered to non-warning entries whose sourceName is the extension
origin. BiDi log.entryAdded delivers nothing at all for extension pages, so
a Playwright-BiDi or Puppeteer-BiDi harness would see nothing and report
success; the code says so where someone would be tempted to simplify it.
This also captures background-page and content-script errors, verified by
probe rather than assumed.

No driver layer is shared with the Chrome suite and the three UI steps are
written twice deliberately: the two backends have no common substrate, and
three steps do not pay for a shim.

Two limits are documented rather than papered over. Error capture is
poll-based, so an error is attributed to a step and not to a moment within
it. Nothing is stubbed; the container runs with --network none instead,
which proves no request escaped but cannot report which were attempted.
2026-08-12 08:43:06 +00:00
15 changed files with 136 additions and 1348 deletions

View File

@@ -210,43 +210,29 @@ deterministic. Navigation uses **classic** WebDriver `POST /session/{id}/url`,
because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright. because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
**Any uncaught error from a `moz-extension://` source fails the run**, including **Any uncaught error from a `moz-extension://` source fails the run**, including
errors from the background page, which the suite never navigates to: a `throw` errors from the background page and content scripts, which the suite never
at the top of `src/background/index.js` kills the background page and fails navigates to. Errors are read from the privileged `nsIConsoleService` in
step 1. Content-script errors should arrive by the same route, but this suite Marionette's chrome context and filtered to non-warning entries whose
does not exercise it and does not claim it — with `--network none` there is no `sourceName` is the extension origin. That mechanism is not a stylistic choice.
`http://` page for a content script to be injected into. Errors from add-on WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
install and background startup are folded into step 1 rather than discarded. plain `http://` page it reports uncaught errors with stack traces, and on the
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome `moz-extension://` popup it reports zero events, because Firefox's remote agent
context and filtered to non-warning entries whose `sourceName` is the extension excludes extension browsing contexts from BiDi observation. Any harness built on
origin. That mechanism is not a stylistic choice. WebDriver BiDi's Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://` success, which is exactly the vacuous check this repo has already shipped twice.
page it reports uncaught errors with stack traces, and on the `moz-extension://` Do not migrate this suite to BiDi.
popup it reports zero events, because Firefox's remote agent excludes extension
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
the vacuous check this repo has already shipped twice. Do not migrate this suite
to BiDi.
Two limits are worth knowing, both real differences from the Chrome suite: Two limits are worth knowing, both real differences from the Chrome suite:
- **Error capture is poll-based, not event-streamed.** The console is drained at - **Error capture is poll-based, not event-streamed.** The console is drained at
each step boundary, so an error is attributed to the step it was drained each step boundary, so an error is attributed to the step it was drained
after, not to a moment within it. The window that is drained runs from add-on after. Nothing is lost, but an error cannot be located within a step the way
install to one second after the last step returns; an error logged later than the Chrome suite's `pageerror` events can.
that ~1s tail is missed entirely, because the browser is torn down first. - **Nothing is stubbed.** There is no fixture layer; the container runs with
Within that window nothing is dropped, but an error cannot be located within a `--network none` instead, so the run is offline and deterministic and no
step the way the Chrome suite's `pageerror` events can. request can escape. The extension swallows its own fetch failures, so the
- **Nothing is stubbed, which inverts the coverage of network-dependent code.** flows are unaffected. This is weaker than the Chrome suite's interception: it
There is no fixture layer; the container runs with `--network none` instead,
so the run is offline and deterministic and no request can escape. The
extension swallows its own fetch failures, so the flows are unaffected — but
every network call fails, so only the _failure_ branches of code that depends
on one are ever executed. A `ReferenceError` in the success path of
`renderTransactions`, or of price or balance rendering, passes this suite
green. The offline run is also weaker than the Chrome suite's interception: it
proves nothing got out, but it cannot report which requests were attempted. proves nothing got out, but it cannot report which requests were attempted.
Closing that gap needs a fixture layer, deliberately out of scope for this
harness.
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser `make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
@@ -567,14 +553,6 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front so the popup can never reopen onto one of them with no password prompt in front
of it. of it.
Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret
survives in a hidden view once the user has navigated away by any route. That
covers the revealed private key and recovery phrase, the recovery phrase,
private key or extended private key entered on AddWallet, and the password typed
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
#### Welcome (`welcome`) #### Welcome (`welcome`)
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root - **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
@@ -685,15 +663,10 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- "Reveal" (correct password) → decrypts the wallet secret, derives this - "Reveal" (correct password) → decrypts the wallet secret, derives this
address's key, hides the password input and shows the key (no screen address's key, hides the password input and shows the key (no screen
change) change)
- "Reveal" (wrong password) → full-sentence error on the error line, nothing - "Reveal" (wrong password) → "Wrong password." on the error line, nothing
revealed (no screen change) revealed
- "Back" → previous screen (AddressDetail) - "Back" → clears the key and password from the DOM, then → previous screen
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is (AddressDetail)
written into the page until the password is accepted; the key is never stored
in state, and it is wiped from the page whenever the screen is left by any
route, including the Settings gear. A decrypt still running when the screen is
left is discarded rather than written. The screen is not restorable, so
reopening the popup lands on Home rather than back on the key.
#### AddressToken (`address-token`) #### AddressToken (`address-token`)
@@ -782,23 +755,10 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- 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. The wait is - **Behavior**: Polls `getTransactionReceipt` every 10 seconds.
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**
- A lookup that answers "no receipt" 60 seconds or more after broadcast → - 60 seconds without confirmation → **ErrorTx** (timeout message)
**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`)

21
TODO.md
View File

@@ -51,24 +51,9 @@ undefined identifiers, which is how
client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
privileged console service in Marionette's chrome context, because BiDi privileged console service in Marionette's chrome context, because BiDi
`log.entryAdded` reports nothing at all for extension pages, and errors logged `log.entryAdded` reports nothing at all for extension pages; demonstrated
during add-on install and background startup are folded into step 1 instead of discriminating by exiting 1 on a build with one import removed and 0 with it
being cleared; demonstrated discriminating by exiting 1 on a `throw` at the restored ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
top of `src/background/index.js` and on a build with one import removed, and 0
on the branch as it stands
([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no
longer both render on one tick, no timer or in-flight lookup outlives its
wait, a failed receipt lookup no longer counts as a timeout (but six in a row
end the wait, reported as an unreachable network rather than as a timeout),
and the wait now resumes after a popup close
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
- 2026-08-12: The private key export screen now wipes the key from the page
whenever it is left by any route, and a decrypt still in flight when the
screen is left is discarded instead of written; the same `onViewLeave()`
cleanup was extended to every other screen holding secret material in the DOM
(AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
- 2026-08-12: An xprv wallet already in storage that was imported from a - 2026-08-12: An xprv wallet already in storage that was imported from a
non-master key is detected from the depth of its stored `xpub`, explained in non-master key is detected from the depth of its stored `xpub`, explained in
the wallet list, and blocked from signing, sending and private-key export the wallet list, and blocked from signing, sending and private-key export

View File

@@ -165,12 +165,6 @@ 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

@@ -22,7 +22,6 @@ 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",
]); ]);

View File

@@ -1,11 +1,4 @@
const { const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const { const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
@@ -73,23 +66,13 @@ function switchMode(mode) {
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode]; $("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
} }
// Wipe the secret material this screen holds in the DOM: a generated or function show() {
// pasted recovery phrase, an imported private key or extended private key,
// and the password that would encrypt them. Registered as the view-leave
// handler as well as run on entry, so none of it survives in the hidden
// view after the user navigates away by any route, including the Settings
// gear and the import itself.
function clear() {
$("wallet-mnemonic").value = ""; $("wallet-mnemonic").value = "";
$("import-private-key").value = ""; $("import-private-key").value = "";
$("import-xprv-key").value = ""; $("import-xprv-key").value = "";
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden"; $("add-wallet-phrase-warning").style.visibility = "hidden";
}
function show() {
clear();
switchMode("mnemonic"); switchMode("mnemonic");
showView("add-wallet"); showView("add-wallet");
} }
@@ -305,8 +288,6 @@ async function importXprvKey(ctx) {
} }
function init(ctx) { function init(ctx) {
onViewLeave("add-wallet", clear);
// Tab click handlers // Tab click handlers
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic")); $("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
$("tab-privkey").addEventListener("click", () => switchMode("privkey")); $("tab-privkey").addEventListener("click", () => switchMode("privkey"));

View File

@@ -2,6 +2,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
@@ -26,7 +27,8 @@ const {
} = require("./send"); } = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const exportPrivkey = require("./exportPrivkey"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the // The defect of the wallet the selected address belongs to, or null. Both the
@@ -319,12 +321,81 @@ function init(_ctx) {
showFlash(defect.shortMessage); showFlash(defect.shortMessage);
return; return;
} }
// No pushCurrentView() here: exportPrivkey.show() can return pushCurrentView();
// without navigating, so it does its own push. const wallet = state.wallets[state.selectedWallet];
exportPrivkey.show(state.selectedWallet, state.selectedAddress); const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
const exportAddrContainer = $("export-privkey-dot").parentElement;
exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(exportAddrContainer);
$("export-privkey-password").value = "";
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
}); });
exportPrivkey.init(); $("btn-export-privkey-confirm").addEventListener("click", async () => {
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").style.visibility = "visible";
return;
}
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const wallet = state.wallets[state.selectedWallet];
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
secret,
);
const privateKey = signer.privateKey;
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").style.visibility = "hidden";
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").style.visibility = "visible";
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
goBack();
});
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -7,7 +7,6 @@ const {
hideError, hideError,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { const {
@@ -462,24 +461,7 @@ function findActiveWallet() {
return null; return null;
} }
// Drop the password from the DOM when either approval screen is left. The
// approval window navigates on after a signature — approve-tx goes to the
// wait screen — and the password must not sit in the hidden view for the
// life of that window.
function clearTxPassword() {
$("approve-tx-password").value = "";
hideError("approve-tx-error");
}
function clearSignPassword() {
$("approve-sign-password").value = "";
hideError("approve-sign-error");
}
function init(ctx) { function init(ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
$("approve-remember").addEventListener("change", async () => { $("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked; state.rememberSiteChoice = $("approve-remember").checked;
await saveState(); await saveState();

View File

@@ -21,7 +21,6 @@ const {
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
@@ -391,17 +390,7 @@ async function checkRecipientHistory(txInfo) {
} }
} }
// Drop the password from the DOM. Registered as the view-leave handler so
// it does not sit in the hidden view once the screen navigates on — to the
// wait screen after a send, or anywhere else the user goes.
function clearPassword() {
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
}
function init(ctx) { function init(ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
const password = $("confirm-tx-password").value; const password = $("confirm-tx-password").value;
if (!password) { if (!password) {

View File

@@ -1,11 +1,4 @@
const { const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { const {
@@ -16,34 +9,22 @@ const {
let deleteWalletIndex = null; let deleteWalletIndex = null;
let ctx = null; let ctx = null;
// Drop the password from the DOM and the wallet selection from the
// closure. Registered as the view-leave handler as well as run on entry,
// so the typed password does not sit in the hidden view after the user
// navigates away by any route, including the Settings gear.
function clear() {
deleteWalletIndex = null;
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
}
function show(walletIdx) { function show(walletIdx) {
clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent = $("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1); wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear);
// No wipe here: goBack() routes through showView(), which runs the
// leave hook.
$("btn-delete-wallet-back").addEventListener("click", () => { $("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null;
goBack(); goBack();
}); });

View File

@@ -1,174 +0,0 @@
// Private key export for a single address.
//
// The key controls the address outright — anyone holding it can move every
// token in it, from any device, forever — so this screen is handled under
// the same rules as the recovery phrase screen (./showPhrase.js):
//
// 1. Nothing is decrypted, no key is derived, and nothing is written into
// the DOM until decryptWithPassword has accepted the password.
// 2. Leaving the screen by any path wipes it, via the onViewLeave hook,
// and a decrypt still in flight when that happens is discarded
// instead of written (revealGeneration).
// 3. The key never reaches the logger. This module deliberately does not
// import src/shared/log.js.
//
// The key is also never assigned to `state`, so it cannot be persisted to
// extension storage, and "export-privkey" is excluded from RESTORABLE_VIEWS
// so the popup can never reopen onto it.
const {
$,
showView,
showFlash,
flashCopyFeedback,
goBack,
onViewLeave,
pushCurrentView,
renderAddressHtml,
attachCopyHandlers,
} = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const makeBlockie = require("ethereum-blockies-base64");
const VIEW = "export-privkey";
let walletIndex = null;
let addressIndex = null;
// Bumped by every clear(), which is what leaving the screen runs. reveal()
// captures it before awaiting the decrypt and refuses to touch the DOM if
// it has moved: a decrypt still in flight when the screen is left would
// otherwise write the key *after* the wipe, with nothing scheduled to wipe
// it again, leaving it in the hidden view for the life of the popup.
let revealGeneration = 0;
// True only if the reveal that captured `generation` is still the live one:
// the screen has not been left, cleared, or re-entered for another address
// since it started.
function isCurrentReveal(generation) {
return (
generation === revealGeneration &&
walletIndex !== null &&
addressIndex !== null &&
state.currentView === VIEW
);
}
function fail(message) {
$("export-privkey-flash").textContent = message;
$("export-privkey-flash").style.visibility = "visible";
}
// Wipe every trace of the key and drop the address selection. Safe to call
// when nothing was ever revealed, and safe to call twice.
function clear() {
walletIndex = null;
addressIndex = null;
revealGeneration += 1;
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
}
function show(walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const addr = wallet && wallet.addresses[addrIdx];
if (!addr) {
showFlash("That address is no longer available.");
return;
}
clear();
walletIndex = walletIdx;
addressIndex = addrIdx;
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const img = document.createElement("img");
img.src = makeBlockie(addr.address);
img.width = 48;
img.height = 48;
img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%";
blockieEl.appendChild(img);
$("export-privkey-title").textContent =
wallet.name + " — Address " + (addrIdx + 1);
const addrContainer = $("export-privkey-dot").parentElement;
addrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(addrContainer);
// Pushed here rather than by the caller: this function can return
// without navigating, and a push that happened anyway would leave an
// entry on the stack that no screen transition matches.
pushCurrentView();
showView(VIEW);
}
async function reveal() {
const password = $("export-privkey-password").value;
if (!password) {
fail("Password is required.");
return;
}
if (walletIndex === null) {
fail("No address is selected.");
return;
}
const wallet = state.wallets[walletIndex];
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const generation = revealGeneration;
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
// The only suspension point in this view, and the gate on the only
// place a secret is written: if the screen was left while the
// decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written.
if (!isCurrentReveal(generation)) return;
const signer = getSignerForAddress(wallet, addressIndex, secret);
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
fail("That password is not correct. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
}
function init() {
onViewLeave(VIEW, clear);
// No wipe here: goBack() routes through showView(), which runs the
// leave hook. A per-button wipe would only cover this one path.
$("btn-export-privkey-back").addEventListener("click", () => {
goBack();
});
$("btn-export-privkey-confirm").addEventListener("click", reveal);
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (!key) return;
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
});
}
module.exports = { init, show };

View File

@@ -16,36 +16,11 @@ 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;
// Identifies the wait currently on screen. Bumped by endWait(), so a timer function clearTimers() {
// 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;
@@ -72,13 +47,8 @@ function blockNumberHtml(blockNumber) {
return copyableHtml(num) + etherscanLinkHtml(link); return copyableHtml(num) + etherscanLinkHtml(link);
} }
// Render the wait view and start polling for the receipt. broadcastTime is function showWait(txInfo, txHash) {
// when the transaction was broadcast, which is what the elapsed counter and clearTimers();
// 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;
@@ -86,130 +56,41 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
$("wait-tx-hash").innerHTML = txHashHtml(txHash); $("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx"); attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait const broadcastTime = Date.now();
// instead of silently abandoning it. $("wait-tx-status").textContent = "Waiting for confirmation... 0s";
state.viewData = {
pendingWait: {
txInfo: txInfo,
hash: txHash,
broadcastTime: broadcastTime,
},
};
function renderElapsed() { elapsedTimer = setInterval(() => {
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);
let consecutiveFailures = 0; pollTimer = setInterval(async () => {
async function poll() {
if (id !== waitId) return;
let receipt = null;
let answered = true;
try { try {
receipt = await provider.getTransactionReceipt(txHash); const 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
// flight, in which case this result must not touch the view. const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
if (id !== waitId) return; if (elapsed >= 60) {
// 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) {
endWait(); clearTimers();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
state.viewData = { state.viewData = {
@@ -301,7 +182,7 @@ function renderSuccess() {
} }
function showError(txInfo, txHash, message) { function showError(txInfo, txHash, message) {
endWait(); clearTimers();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
state.viewData = { state.viewData = {
@@ -337,9 +218,6 @@ 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;
@@ -364,12 +242,4 @@ function init(_ctx) {
$("btn-error-tx-done").addEventListener("click", navigateBack); $("btn-error-tx-done").addEventListener("click", navigateBack);
} }
module.exports = { module.exports = { init, showWait, showError, renderSuccess, renderError };
init,
showWait,
restoreWait,
endWait,
showError,
renderSuccess,
renderError,
};

View File

@@ -345,15 +345,8 @@ class Driver {
// built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and // built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and
// reports success. Do not "simplify" this back to BiDi. // reports success. Do not "simplify" this back to BiDi.
// //
// nsIConsoleService is not per-page: it also carries errors from the // nsIConsoleService sees everything, including the background page and
// background page, which BiDi would not have covered even if it worked. // content scripts, which BiDi would not have covered even if it worked.
// Background-page capture is verified by probe — a throw at the top of
// src/background/index.js, which kills the background page outright, fails
// the run. Content-script errors should arrive by the same route, but that
// is UNVERIFIED here and must not be claimed: the container runs with
// --network none, so there is no http:// page for a content script to be
// injected into and this suite never exercises one.
//
// Warnings are excluded so the semantics match Playwright's pageerror: // Warnings are excluded so the semantics match Playwright's pageerror:
// uncaught errors only. // uncaught errors only.
const READ_ERRORS_SCRIPT = ` const READ_ERRORS_SCRIPT = `
@@ -397,10 +390,6 @@ class ConsoleErrors {
return found || []; return found || [];
} }
// Discards the console outright. Anything logged and not yet taken is
// destroyed unread, so this is only ever correct straight after a
// take(); to move past a window of errors, take() them and report
// them somewhere.
async reset() { async reset() {
await this.driver.executeChrome("Services.console.reset(); return 0;"); await this.driver.executeChrome("Services.console.reset(); return 0;");
} }

View File

@@ -21,13 +21,9 @@
// LIMITATION, and the difference from the Chrome suite worth knowing: error // LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is // capture here is POLL-BASED, not event-streamed. The console service is
// drained at each step boundary, so an error is attributed to the step it // drained at each step boundary, so an error is attributed to the step it
// was drained after, never to a moment within that step. What is drained // was drained after never lost, but never located within the step
// covers the whole run from add-on install to one second after the last // either. The Chrome harness receives pageerror events as they happen and
// step returns — but only that far: an error logged more than that ~1s // can say more. Do not read a green Firefox run as the same claim.
// tail after the last step is never observed at all, because the browser
// is torn down first. The Chrome harness receives pageerror events as they
// happen and can say more. Do not read a green Firefox run as the same
// claim.
"use strict"; "use strict";
@@ -182,20 +178,9 @@ async function main() {
let failed = 0; let failed = 0;
let n = 0; let n = 0;
try { try {
// Drain, never reset: anything the add-on logged while installing // Anything the add-on logged while installing and starting its
// and starting its background page has no earlier step to belong // background page belongs to step 1 rather than to nowhere.
// to, so it is folded into step 1 below. Services.console.reset() await errors.reset();
// here would DELETE it instead, and a background page that throws
// at the top of the file — a dead background page — would then
// produce a fully green run.
let installErrors = [];
let installFailure = null;
try {
installErrors = await errors.take();
} catch (e) {
installFailure =
"could not read the console after install: " + e.message;
}
for (const s of steps) { for (const s of steps) {
n += 1; n += 1;
@@ -219,21 +204,10 @@ async function main() {
failure = failure || "could not read the console: " + e.message; failure = failure || "could not read the console: " + e.message;
} }
if (n === 1) {
found = installErrors.concat(found);
installErrors = [];
failure = failure || installFailure;
installFailure = null;
}
// Any uncaught error from an extension source fails the step // Any uncaught error from an extension source fails the step
// that provoked it, whether or not its assertions passed. // that provoked it, whether or not its assertions passed.
if (!failure && found.length > 0) { if (!failure && found.length > 0) {
failure = failure = "uncaught extension errors during this step";
n === 1
? "uncaught extension errors during add-on install, " +
"background startup or this step"
: "uncaught extension errors during this step";
} }
if (failure) { if (failure) {

View File

@@ -1,331 +0,0 @@
// Tests for the private key export screen (issue #221).
//
// The screen holds the one secret that owns an address outright, so what is
// pinned here is disposal: the key is wiped from the DOM whenever the screen
// is left by any route, and a decrypt still in flight when the screen is
// left never writes at all. That last case is the one a per-button wipe and
// a naive leave hook both miss — the write lands after the wipe, with
// nothing scheduled to wipe it again.
//
// The view is driven against a minimal DOM stub rather than a real browser:
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
// no dependency that needs a document beyond the nodes it reads and writes.
const mockPrivateKey = "0x" + "ab".repeat(32);
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
jest.mock("../src/shared/wallet", () => ({
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const VIEW = "export-privkey";
const PASSWORD = "correct horse battery";
// ------------------------------------------------------------ DOM stub
function makeElement(id, withParent) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
appendChild: () => {},
remove: () => {},
querySelectorAll: () => [],
};
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id, true));
return els.get(id);
},
createElement: () => makeElement("created", false),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
// ------------------------------------------------------------ harness
function load() {
jest.resetModules();
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
const vault = require("../src/shared/vault");
const wallet = require("../src/shared/wallet");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
state.wallets = [
{
name: "Wallet 1",
type: "key",
encryptedSecret: "ciphertext",
addresses: [
{
address: "0x" + "11".repeat(20),
balance: "0.0000",
tokenBalances: [],
},
{
address: "0x" + "22".repeat(20),
balance: "0.0000",
tokenBalances: [],
},
],
},
];
state.viewStack = [];
state.currentView = "address";
exportPrivkey.init();
return { helpers, state, vault, wallet, exportPrivkey };
}
function click(id) {
const el = globalThis.document.getElementById(id);
return Promise.all((el.listeners.click || []).map((fn) => fn()));
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Start a reveal and hand back both the promise it returns and the resolver
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
function startReveal(vault) {
let resolveDecrypt;
let rejectDecrypt;
vault.decryptWithPassword.mockImplementation(
() =>
new Promise((resolve, reject) => {
resolveDecrypt = resolve;
rejectDecrypt = reject;
}),
);
node("export-privkey-password").value = PASSWORD;
const pending = click("btn-export-privkey-confirm");
return {
pending,
resolve: (v) => resolveDecrypt(v),
reject: (e) => rejectDecrypt(e),
};
}
// ------------------------------------------------------------ tests
describe("a decrypt still running when the screen is left", () => {
// The load-bearing case. Without the liveness guard in reveal(), the
// write lands after the leave hook has already wiped, and the key sits
// in the hidden view for the life of the popup.
test("never writes the key into the DOM", async () => {
const { helpers, vault, wallet, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
// The settings gear, mid-decrypt.
helpers.showView("settings");
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
// Nothing was even derived: the guard sits in front of the
// derivation, not just in front of the write.
expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
});
// The generation counter, not merely the current-view check: by the time
// the stale decrypt resolves the user is back on the screen, so a guard
// that only asked "is this view showing?" would let the write through.
test("never writes it after the screen is re-entered", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const stale = startReveal(vault);
helpers.showView("settings");
exportPrivkey.show(0, 1);
expect(node("export-privkey-value").textContent).toBe("");
stale.resolve("wallet secret");
await stale.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
true,
);
});
// Same hole on the failure path: a wrong-password error written after
// the wipe would restore the flash line on a screen the user has left.
test("never writes the failure message either", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
helpers.showView("settings");
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-flash").textContent).toBe("");
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
});
});
describe("a reveal that is not interrupted", () => {
// Guards the guard: a liveness check that rejected every write would
// pass every test above and ship a screen that reveals nothing.
test("puts the key on screen", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
false,
);
// The password is dropped as soon as it has been spent.
expect(node("export-privkey-password").value).toBe("");
});
test("writes nothing before the password is accepted", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
expect(node("export-privkey-value").textContent).toBe("");
reveal.resolve("wallet secret");
await reveal.pending;
});
test("reveals nothing when the password is wrong", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe(
"That password is not correct. Please try again.",
);
});
});
describe("leaving the screen after the key is on it", () => {
async function revealed() {
const loaded = load();
loaded.exportPrivkey.show(0, 0);
const reveal = startReveal(loaded.vault);
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
return loaded;
}
test("the Back button clears the key", async () => {
await revealed();
await click("btn-export-privkey-back");
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password").value).toBe("");
});
test("the settings gear clears the key", async () => {
const { helpers } = await revealed();
helpers.showView("settings");
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password").value).toBe("");
// And the screen is back to its password prompt, not to a result
// panel that would flash an empty well on the next visit.
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
true,
);
expect(
node("export-privkey-password-section").classList.contains(
"hidden",
),
).toBe(false);
});
// Any other navigation: the same hook covers routes that do not exist
// yet, which is the point of registering it on the view rather than on
// the controls that leave it.
test("any other navigation clears the key", async () => {
const { helpers } = await revealed();
helpers.showView("main");
expect(node("export-privkey-value").textContent).toBe("");
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto this screen would put a private key on display with no
// password prompt in front of it, on a popup reopened by accident.
test("the private key export screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
});
test("it is still a registered view", () => {
const { helpers } = load();
expect(helpers.VIEWS).toContain(VIEW);
});
});
describe("the key cannot reach the logger", () => {
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
"utf8",
);
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});

View File

@@ -1,482 +0,0 @@
// Lifecycle tests for the post-broadcast transaction status views
// (src/popup/views/txStatus.js).
//
// The bug these pin down: the receipt poll rendered both outcomes on the tick
// that crossed the 60-second deadline, so a confirmed transaction was replaced
// by "not confirmed within 60 seconds" — the user is told their transaction
// failed when it succeeded. The same shape applies to any callback that
// outlives its wait: a receipt lookup still in flight when the view is left
// must not render over whatever replaced it.
//
// Fake timers make the race deterministic: the receipt promise is already
// resolved when the deadline tick runs, so in the unfixed code showSuccess()
// is always followed by showError() on that tick.
//
// No network: getProvider is mocked at the module boundary and there is no
// jsdom in this repo, so the handful of DOM calls these views make are served
// by the stub below.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
const mockReceiptLookup = jest.fn();
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
refreshBalances: jest.fn(),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// ---------------------------------------------------------------------------
// Minimal DOM. Every element is created on demand and remembered by id, so a
// test can read back what a view wrote into it.
// ---------------------------------------------------------------------------
const elements = new Map();
function makeElement(id) {
const classes = new Set(["view", "hidden"]);
const el = {
id,
textContent: "",
innerHTML: "",
style: {},
classList: {
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c),
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
},
addEventListener: () => {},
querySelectorAll: () => [],
remove: () => {},
prepend: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => getElement(id + "-parent"),
});
return el;
}
function getElement(id) {
if (!elements.has(id)) elements.set(id, makeElement(id));
return elements.get(id);
}
global.document = {
getElementById: (id) => getElement(id),
// escapeHtml() builds a detached div; textContent in, escaped HTML out.
createElement: () => {
const el = { innerHTML: "" };
Object.defineProperty(el, "textContent", {
set(v) {
el.innerHTML = String(v)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
},
});
return el;
},
body: { prepend: () => {} },
addEventListener: () => {},
};
global.window = { location: { search: "" } };
const stored = {};
global.chrome = {
storage: {
local: {
set: (obj) => {
Object.assign(stored, obj);
return Promise.resolve();
},
get: () => Promise.resolve(stored),
},
},
};
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TX_INFO = {
to: RECIPIENT,
amount: "0.0050",
token: "ETH",
tokenSymbol: null,
};
// True when a view element is not hidden.
function visible(view) {
return !getElement("view-" + view).classList.contains("hidden");
}
function waitStatusText() {
return getElement("wait-tx-status").textContent;
}
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear();
mockReceiptLookup.mockReset();
state.wallets = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender: jest.fn() });
});
afterEach(() => {
txStatus.endWait();
jest.useRealTimers();
});
describe("WaitTx receipt/timeout race", () => {
test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => {
// No receipt for the first five polls; the sixth — the tick at
// t=60s, which is also the timeout deadline — returns one.
mockReceiptLookup
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait(TX_INFO, TX_HASH);
expect(visible("wait-tx")).toBe(true);
await jest.advanceTimersByTimeAsync(60000);
expect(visible("success-tx")).toBe(true);
expect(visible("error-tx")).toBe(false);
expect(state.currentView).toBe("success-tx");
expect(state.viewData.blockNumber).toBe(21000000);
expect(state.viewData.message).toBeUndefined();
// And nothing is left running to undo it.
expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(300000);
expect(state.currentView).toBe("success-tx");
expect(mockReceiptLookup).toHaveBeenCalledTimes(6);
});
test("a genuine timeout still shows ErrorTx with the hash", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(TX_INFO, TX_HASH);
await jest.advanceTimersByTimeAsync(60000);
expect(visible("error-tx")).toBe(true);
expect(state.currentView).toBe("error-tx");
expect(state.viewData.message).toMatch(
/not confirmed within 60 seconds/,
);
expect(state.viewData.hash).toBe(TX_HASH);
// The hash section carries the hash and the etherscan link.
expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH);
expect(getElement("error-tx-hash").innerHTML).toContain(
"/tx/" + TX_HASH,
);
expect(jest.getTimerCount()).toBe(0);
});
test("a receipt still in flight when the view is left does not render over it", async () => {
let resolveReceipt;
mockReceiptLookup.mockReturnValue(
new Promise((r) => {
resolveReceipt = r;
}),
);
txStatus.showWait(TX_INFO, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
// User leaves the wait (popup navigation / teardown) while the
// lookup is outstanding, then the lookup finally answers.
txStatus.endWait();
state.currentView = "main";
resolveReceipt({ blockNumber: 21000000 });
await Promise.resolve();
await Promise.resolve();
expect(state.currentView).toBe("main");
expect(visible("success-tx")).toBe(false);
});
test("no timer survives the view being left", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(TX_INFO, TX_HASH);
expect(jest.getTimerCount()).toBeGreaterThan(0);
txStatus.endWait();
expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(120000);
expect(mockReceiptLookup).not.toHaveBeenCalled();
});
});
describe("WaitTx persistence across popup close", () => {
test("restoreWait resumes the poll with the deadline running from broadcast", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(TX_INFO, TX_HASH);
expect(state.viewData.pendingWait.hash).toBe(TX_HASH);
const persisted = JSON.parse(JSON.stringify(state.viewData));
// Popup closes: timers die with the page.
txStatus.endWait();
// 45 seconds pass with the popup shut, then it is reopened.
jest.advanceTimersByTime(45000);
state.viewData = persisted;
expect(txStatus.restoreWait()).toBe(true);
expect(visible("wait-tx")).toBe(true);
// Elapsed is counted from the broadcast, not from the reopen.
expect(waitStatusText()).toBe("Waiting for confirmation... 45s");
// The immediate poll on resume has already run.
await Promise.resolve();
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
// The deadline is 15 seconds away, not 60.
await jest.advanceTimersByTimeAsync(20000);
expect(state.currentView).toBe("error-tx");
});
test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => {
// A wait resumed after the deadline has already passed: the first
// poll is immediate and past 60s, so a thrown lookup must not be
// read as "no receipt". It means "no answer this tick" — keep
// polling, because the transaction may well have confirmed.
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(TX_INFO, TX_HASH);
const persisted = JSON.parse(JSON.stringify(state.viewData));
txStatus.endWait();
// Ten minutes with the popup shut, then it is reopened and the
// first receipt lookup fails transiently.
jest.advanceTimersByTime(600000);
mockReceiptLookup.mockReset();
mockReceiptLookup
.mockRejectedValueOnce(new Error("rpc unavailable"))
.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = persisted;
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
// The wait is still alive: no timeout was declared off one error.
expect(visible("wait-tx")).toBe(true);
expect(visible("error-tx")).toBe(false);
expect(state.currentView).toBe("wait-tx");
expect(jest.getTimerCount()).toBeGreaterThan(0);
// And the next tick answers, so the confirmed transaction is
// reported as confirmed.
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expect(state.viewData.blockNumber).toBe(21000000);
});
test("a lookup returning null past the deadline still times out", async () => {
// The counterpart to the test above: the deadline must still fire
// when the lookup actually answers "no receipt".
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(TX_INFO, TX_HASH);
const persisted = JSON.parse(JSON.stringify(state.viewData));
txStatus.endWait();
jest.advanceTimersByTime(600000);
state.viewData = persisted;
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
expect(state.currentView).toBe("error-tx");
expect(state.viewData.message).toMatch(
/not confirmed within 60 seconds/,
);
});
test("restoreWait reports nothing to resume when no wait is persisted", () => {
state.viewData = {};
expect(txStatus.restoreWait()).toBe(false);
expect(jest.getTimerCount()).toBe(0);
});
test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => {
for (const bad of [
{ hash: TX_HASH, broadcastTime: Date.now() },
{ hash: TX_HASH, txInfo: TX_INFO },
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" },
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN },
{ hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() },
// An object that merely lacks a field startWait() dereferences
// is the shape that actually escaped: txInfo.to reaches
// addressTitle(), which calls address.toLowerCase(). typeof []
// is "object", so an array passes an object check.
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
// 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);
});
});