Compare commits

...

3 Commits

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

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

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

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

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

The 60-second threshold and the timeout copy are unchanged.
2026-08-12 08:55:44 +00:00
23712b53cb fix: wipe the exported private key from the DOM on any view leave (closes #221)
All checks were successful
check / check (push) Successful in 29s
2026-08-12 10:54:37 +02:00
bd4bdcafc7 fix: explain a stored non-master xprv wallet instead of throwing at signing time (closes #234)
All checks were successful
check / check (push) Successful in 30s
2026-08-12 10:41:49 +02:00
18 changed files with 1772 additions and 122 deletions

View File

@@ -436,7 +436,11 @@ The core hierarchy is **Wallets → Addresses**:
multi-address behavior as an HD wallet, including the "+" button and the
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
back up.
back up. Only a master key may be imported; an xprv wallet already in
storage that was imported from a non-master key is detected from the depth
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
wallet list, and blocked from signing, sending and private-key export. It
is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
@@ -487,6 +491,14 @@ 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
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`)
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
@@ -597,10 +609,15 @@ of it.
- "Reveal" (correct password) → decrypts the wallet secret, derives this
address's key, hides the password input and shows the key (no screen
change)
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
revealed
- "Back" → clears the key and password from the DOM, then → previous screen
(AddressDetail)
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change)
- "Back" → previous screen (AddressDetail)
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
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`)
@@ -689,10 +706,23 @@ of it.
- To: color dot + full address + etherscan link
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds.
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
persisted: closing and reopening the popup resumes the poll, with the elapsed
counter and the timeout deadline still measured from the original broadcast. A
lookup that fails is retried on the next tick rather than counted as a missing
receipt, because a failed lookup says nothing about the transaction; but six
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
that never answers cannot leave it running indefinitely. Any lookup that
answers resets that count.
- **Transitions**:
- Receipt found → **SuccessTx**
- 60 seconds without confirmation → **ErrorTx** (timeout message)
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
**ErrorTx** (timeout message)
- Six consecutive failed lookups → **ErrorTx**, with a message naming the
unreachable network and pointing at the RPC URL in Settings. This is a
different fact from the timeout — the chain was never asked — and says so
- Exactly one outcome: a receipt found on the tick that crosses the deadline
wins, and no outcome can be rendered over another
#### SuccessTx (`success-tx`)

17
TODO.md
View File

@@ -44,6 +44,23 @@ undefined identifiers, which is how
# Completed Steps
- 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
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
instead of throwing on the send screen
([#234](https://git.eeqj.de/sneak/AutistMask/issues/234)).
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
`0`, so the low-holder rule declines to judge an unknown count instead of
hiding a legitimate token as spam, in both the transaction history and the

View File

@@ -165,6 +165,12 @@ function restoreView() {
fallbackView();
}
break;
case "wait-tx":
// Resumes the receipt poll from the persisted broadcast time.
if (!txStatus.restoreWait()) {
fallbackView();
}
break;
case "success-tx":
if (state.viewData && state.viewData.hash) {
txStatus.renderSuccess();

View File

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

View File

@@ -1,4 +1,11 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
const {
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const {
generateMnemonic,
hdWalletFromMnemonic,
@@ -66,13 +73,23 @@ function switchMode(mode) {
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
}
function show() {
// Wipe the secret material this screen holds in the DOM: a generated or
// 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 = "";
$("import-private-key").value = "";
$("import-xprv-key").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden";
}
function show() {
clear();
switchMode("mnemonic");
showView("add-wallet");
}
@@ -288,6 +305,8 @@ async function importXprvKey(ctx) {
}
function init(ctx) {
onViewLeave("add-wallet", clear);
// Tab click handlers
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));

View File

@@ -2,7 +2,6 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
@@ -27,8 +26,17 @@ const {
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const exportPrivkey = require("./exportPrivkey");
const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the
// send and the private-key export path check it before asking for a password,
// so a wallet that cannot derive its keys says so instead of failing after the
// user has typed one in.
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
}
let ctx;
@@ -254,6 +262,11 @@ function init(_ctx) {
});
$("btn-send").addEventListener("click", () => {
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
@@ -298,81 +311,20 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
pushCurrentView();
const wallet = state.wallets[state.selectedWallet];
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");
});
$("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";
// There is no private key to export for an address this wallet
// cannot derive. Without this the export screen would take a
// password and then report it as wrong.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
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");
}
// No pushCurrentView() here: exportPrivkey.show() can return
// without navigating, so it does its own push.
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
});
$("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();
});
exportPrivkey.init();
}
module.exports = { init, show };

View File

@@ -35,6 +35,7 @@ const {
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { walletDefect } = require("../../shared/walletDefects");
let ctx;
@@ -338,6 +339,11 @@ function init(_ctx) {
});
$("btn-address-token-send").addEventListener("click", () => {
const defect = walletDefect(state.wallets[state.selectedWallet]);
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress

View File

@@ -7,6 +7,7 @@ const {
hideError,
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
@@ -21,6 +22,7 @@ const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { getProvider } = require("../../shared/balances");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
@@ -280,6 +282,7 @@ function showTxApproval(details) {
showView("approve-tx");
attachCopyHandlers("view-approve-tx");
gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
}
function decodeHexMessage(hex) {
@@ -379,6 +382,7 @@ function showSignApproval(details) {
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
}
function show(id) {
@@ -431,6 +435,20 @@ function setSignButtonBusy(busy) {
$("btn-approve-sign").classList.toggle("text-muted", busy);
}
// Say so on the approval screen itself, and disable the approve button, when
// the active address belongs to a wallet whose keys cannot be derived. Without
// this the screen would take a password and fail after deriving it. Reject
// stays available; the wallet is not touched. Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId) {
const active = findActiveWallet();
const defect = active ? walletDefect(active.wallet) : null;
if (!defect) return false;
showError(errorId, defect.shortMessage);
$(buttonId).disabled = true;
$(buttonId).classList.add("text-muted");
return true;
}
// Locate the wallet and the address index owning the currently active
// address. Returns null when no wallet holds it.
function findActiveWallet() {
@@ -444,7 +462,24 @@ function findActiveWallet() {
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) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
$("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked;
await saveState();
@@ -492,6 +527,14 @@ function init(ctx) {
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-tx-error", defect.shortMessage);
setTxButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signed transaction does.
let decryptedSecret;
@@ -583,6 +626,14 @@ function init(ctx) {
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-sign-error", defect.shortMessage);
setSignButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;

View File

@@ -21,6 +21,7 @@ const {
renderAddressHtml,
attachCopyHandlers,
goBack,
onViewLeave,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
@@ -390,7 +391,17 @@ 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) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
const password = $("confirm-tx-password").value;
if (!password) {

View File

@@ -1,4 +1,11 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
const {
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const {
@@ -9,22 +16,34 @@ const {
let deleteWalletIndex = 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) {
clear();
deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
showView("delete-wallet-confirm");
}
function init(_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", () => {
deleteWalletIndex = null;
goBack();
});

View File

@@ -0,0 +1,174 @@
// 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

@@ -21,6 +21,10 @@ const {
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const {
walletDefect,
walletDefectHtml,
} = require("../../shared/walletDefects");
const {
formatUsd,
getPrice,
@@ -214,25 +218,23 @@ async function loadHomeTxs(ctx) {
}
}
function render(ctx) {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
renderActiveAddress();
return;
}
// The wallet list markup. Pure: it reads state and returns a string, so the
// list can be asserted on without a DOM.
function walletListHtml() {
let html = "";
state.wallets.forEach((wallet, wi) => {
const defect = walletDefect(wallet);
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd" || wallet.type === "xprv") {
// No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce
// under the standard path.
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
}
html += `</div>`;
html += walletDefectHtml(wallet);
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
@@ -260,7 +262,20 @@ function render(ctx) {
html += `</div>`;
});
container.innerHTML = html;
return html;
}
function render(ctx) {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
renderActiveAddress();
return;
}
container.innerHTML = walletListHtml();
container.querySelectorAll(".address-row").forEach((row) => {
row.addEventListener("click", async () => {
@@ -348,6 +363,13 @@ function render(ctx) {
loadHomeTxs(ctx);
}
// The defect of the wallet the selected address belongs to, or null. Call
// after selectActiveAddress().
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
}
function selectActiveAddress() {
for (let wi = 0; wi < state.wallets.length; wi++) {
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
@@ -371,6 +393,13 @@ function init(ctx) {
showFlash("No active address selected.");
return;
}
// Before the balance check and before any password is asked for: this
// wallet cannot sign at all, so the send screen is a dead end.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr = currentAddress();
if (!addr.balance || parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
@@ -396,4 +425,4 @@ function init(ctx) {
});
}
module.exports = { init, render };
module.exports = { init, render, walletListHtml };

View File

@@ -16,11 +16,36 @@ const { state, saveState, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as
// a timeout. Both are documented in the WaitTx section of README.md.
const POLL_INTERVAL_MS = 10000;
const TIMEOUT_MS = 60000;
// How many receipt lookups may fail in a row before the wait is ended and
// the failure reported. A lookup that throws says nothing about the
// transaction, so one must not end the wait — but an RPC that never answers
// (a mistyped URL in settings is the ordinary case) must not leave the wait
// running forever either, least of all a persisted one that every popup
// open would resume. Six is 60 seconds at the poll cadence: the same
// patience the confirmation deadline gets. Any lookup that answers, with a
// receipt or with null, resets the count.
const MAX_CONSECUTIVE_LOOKUP_FAILURES = 6;
let ctx;
let elapsedTimer = null;
let pollTimer = null;
function clearTimers() {
// Identifies the wait currently on screen. Bumped by endWait(), so a timer
// callback or an in-flight receipt lookup that outlives its wait can tell
// that it is stale and leave the current view alone. Without it, a receipt
// resolving after the wait has ended renders over whatever view replaced it.
let waitId = 0;
// End the wait on screen: stop its timers and invalidate its pending async
// work. Called on receipt, on timeout, when a new wait starts, and when the
// user navigates away.
function endWait() {
waitId++;
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
@@ -47,8 +72,13 @@ function blockNumberHtml(blockNumber) {
return copyableHtml(num) + etherscanLinkHtml(link);
}
function showWait(txInfo, txHash) {
clearTimers();
// Render the wait view and start polling for the receipt. broadcastTime is
// when the transaction was broadcast, which is what the elapsed counter and
// the timeout deadline are both measured from; pollNow runs one lookup
// immediately instead of waiting a full poll interval.
function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait();
const id = waitId;
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
@@ -56,41 +86,130 @@ function showWait(txInfo, txHash) {
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx");
const broadcastTime = Date.now();
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
// Persisted so closing and reopening the popup resumes this wait
// instead of silently abandoning it.
state.viewData = {
pendingWait: {
txInfo: txInfo,
hash: txHash,
broadcastTime: broadcastTime,
},
};
elapsedTimer = setInterval(() => {
function renderElapsed() {
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
$("wait-tx-status").textContent =
"Waiting for confirmation... " + elapsed + "s";
}
renderElapsed();
elapsedTimer = setInterval(() => {
if (id !== waitId) return;
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl);
pollTimer = setInterval(async () => {
let consecutiveFailures = 0;
async function poll() {
if (id !== waitId) return;
let receipt = null;
let answered = true;
try {
const receipt = await provider.getTransactionReceipt(txHash);
if (receipt) {
showSuccess(txInfo, txHash, receipt.blockNumber);
}
receipt = await provider.getTransactionReceipt(txHash);
} catch (e) {
// A thrown lookup means "no answer this tick", not "no
// receipt": the RPC failed, the chain said nothing. Declaring
// the timeout off it would report a confirmed transaction as
// failed — which matters most on a resumed wait, where the
// first poll is already past the deadline.
answered = false;
log.errorf("poll receipt failed:", e.message);
}
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
if (elapsed >= 60) {
// The lookup is async: the wait may have ended while it was in
// flight, in which case this result must not touch the view.
if (id !== waitId) return;
// Exactly one outcome per wait. A receipt wins even on the tick
// that crosses the deadline, because the transaction did confirm.
if (receipt) {
showSuccess(txInfo, txHash, receipt.blockNumber);
return;
}
if (!answered) {
consecutiveFailures++;
// The failure is the user's news, and it is a different fact
// from "the transaction did not confirm" — the chain was never
// asked. Ending the wait here is what keeps it bounded and
// gives the user a Done button to leave by.
if (consecutiveFailures >= MAX_CONSECUTIVE_LOOKUP_FAILURES) {
showError(
txInfo,
txHash,
"The network could not be reached to check this transaction — " +
MAX_CONSECUTIVE_LOOKUP_FAILURES +
" lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
);
}
// Otherwise keep polling: the next tick may answer.
return;
}
consecutiveFailures = 0;
if (Date.now() - broadcastTime >= TIMEOUT_MS) {
showError(
txInfo,
txHash,
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
);
}
}, 10000);
}
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
showView("wait-tx");
if (pollNow) poll();
}
function showWait(txInfo, txHash) {
startWait(txInfo, txHash, Date.now(), false);
}
// Resume a wait persisted by a previous popup session. The deadline still
// runs from the original broadcast, so a wait that has already outlived it
// resolves on the immediate first poll rather than restarting the clock.
// Returns false when there is nothing resumable to resume. Every field
// startWait() goes on to use is validated, not just the presence of the
// containers: txInfo.to reaches addressTitle(), which calls
// address.toLowerCase(), and txInfo.amount is rendered into the summary, so
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
const w = d.pendingWait;
if (!w.hash) return false;
// typeof [] is "object", so an array passes an object check.
const info = w.txInfo;
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), and both fields render harmlessly when empty, so refusing it
// would abandon a wait the live path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false;
}
startWait(w.txInfo, w.hash, w.broadcastTime, true);
return true;
}
function showSuccess(txInfo, txHash, blockNumber) {
clearTimers();
endWait();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
state.viewData = {
@@ -182,7 +301,7 @@ function renderSuccess() {
}
function showError(txInfo, txHash, message) {
clearTimers();
endWait();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
state.viewData = {
@@ -218,6 +337,9 @@ function isApprovalPopup() {
}
function navigateBack() {
// Nothing should still be polling by now, but leaving a view is the
// point at which its timers must be gone.
endWait();
if (isApprovalPopup()) {
window.close();
return;
@@ -242,4 +364,12 @@ function init(_ctx) {
$("btn-error-tx-done").addEventListener("click", navigateBack);
}
module.exports = { init, showWait, showError, renderSuccess, renderError };
module.exports = {
init,
showWait,
restoreWait,
endWait,
showError,
renderSuccess,
renderError,
};

View File

@@ -120,9 +120,24 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
const node =
masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH);
return node.deriveChild(addrIndex);
// Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js
// catches it at list-render time; this is the backstop behind that.
const node = parseExtendedKey(decryptedSecret);
if (!node || !node.privateKey) {
throw new Error(
"This wallet's stored key is not a valid extended private " +
"key, so it cannot sign.",
);
}
if (node.depth !== MASTER_DEPTH) {
throw new Error(
"This wallet was imported from an extended private key that " +
"is not a master key, so it cannot sign.",
);
}
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
}
return new Wallet(decryptedSecret);
}
@@ -142,6 +157,7 @@ function walletHasRecoveryPhrase(walletData) {
module.exports = {
generateMnemonic,
parseExtendedKey,
deriveAddressFromXpub,
hdWalletFromMnemonic,
hdWalletFromXprv,

View File

@@ -0,0 +1,86 @@
// Wallets already in stored state whose key cannot be used, and the copy that
// explains them.
//
// Refusing a non-master extended private key at import time does nothing for a
// wallet imported before that refusal existed. Such a wallet is detected here,
// at wallet-list render time, so the user meets the explanation on the list
// screen rather than an exception on the send screen. Nothing here modifies or
// removes a wallet: the record is the user's data.
const { parseExtendedKey } = require("./wallet");
const NON_MASTER_XPRV = "non-master-xprv";
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
// the key that was imported: the current import path derives the absolute
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
// four levels as a relative path beneath whatever depth it was given. A master
// import therefore stores a depth-4 xpub and a depth-d import stores depth
// d + 4, which makes the stored xpub an exact read on the imported key's
// depth — and it is readable without the password, unlike the key itself.
const BIP44_ETH_XPUB_DEPTH = 4;
const DEFECTS = {
[NON_MASTER_XPRV]: {
id: NON_MASTER_XPRV,
heading: "This wallet's addresses were derived incorrectly.",
paragraphs: [
"This wallet was imported from an extended private key that is " +
"not a master key. An earlier version applied the Ethereum " +
"derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " +
"produces under the standard path.",
"Signing and sending are disabled for this wallet. The addresses " +
"do descend from the extended private key you imported, so " +
"anything they hold is still reachable by software that " +
"repeats the same non-standard derivation. Check them in a " +
"block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " +
"changed or removed, and this wallet stays until you delete " +
"it yourself.",
],
// One sentence for the places that have room for one: the flash on a
// blocked Send, the inline error on the approval screens.
shortMessage:
"This wallet cannot sign, because it was imported from an " +
"extended private key that is not a master key. The wallet list " +
"explains what happened.",
},
};
// The defect record for a wallet, or null if there is nothing wrong with it
// that this module can see. Read-only.
//
// A wallet whose xpub will not parse gets null rather than a defect: there is
// no basis in that case to tell the user their key was not a master key, and a
// wrong explanation is worse than none.
function walletDefect(walletData) {
if (!walletData || walletData.type !== "xprv") return null;
const node = parseExtendedKey(walletData.xpub);
if (!node) return null;
if (node.depth === BIP44_ETH_XPUB_DEPTH) return null;
return DEFECTS[NON_MASTER_XPRV];
}
// The notice block for the wallet list, or "" for a wallet with no defect.
// The copy is fixed text from this module, so it needs no escaping.
function walletDefectHtml(walletData) {
const defect = walletDefect(walletData);
if (!defect) return "";
let html =
'<div class="border border-red-500 border-dashed p-2 my-1 text-xs text-red-500">';
html += `<div class="font-bold mb-1">${defect.heading}</div>`;
for (const p of defect.paragraphs) {
html += `<p class="mb-1">${p}</p>`;
}
html += "</div>";
return html;
}
module.exports = {
NON_MASTER_XPRV,
walletDefect,
walletDefectHtml,
};

331
tests/exportPrivkey.test.js Normal file
View File

@@ -0,0 +1,331 @@
// 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/);
});
});

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

@@ -0,0 +1,482 @@
// Lifecycle tests for the post-broadcast transaction status views
// (src/popup/views/txStatus.js).
//
// The bug these pin down: the receipt poll rendered both outcomes on the tick
// that crossed the 60-second deadline, so a confirmed transaction was replaced
// by "not confirmed within 60 seconds" — the user is told their transaction
// failed when it succeeded. The same shape applies to any callback that
// outlives its wait: a receipt lookup still in flight when the view is left
// must not render over whatever replaced it.
//
// Fake timers make the race deterministic: the receipt promise is already
// resolved when the deadline tick runs, so in the unfixed code showSuccess()
// is always followed by showError() on that tick.
//
// No network: getProvider is mocked at the module boundary and there is no
// jsdom in this repo, so the handful of DOM calls these views make are served
// by the stub below.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
const mockReceiptLookup = jest.fn();
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
refreshBalances: jest.fn(),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// ---------------------------------------------------------------------------
// Minimal DOM. Every element is created on demand and remembered by id, so a
// test can read back what a view wrote into it.
// ---------------------------------------------------------------------------
const elements = new Map();
function makeElement(id) {
const classes = new Set(["view", "hidden"]);
const el = {
id,
textContent: "",
innerHTML: "",
style: {},
classList: {
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c),
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
},
addEventListener: () => {},
querySelectorAll: () => [],
remove: () => {},
prepend: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => getElement(id + "-parent"),
});
return el;
}
function getElement(id) {
if (!elements.has(id)) elements.set(id, makeElement(id));
return elements.get(id);
}
global.document = {
getElementById: (id) => getElement(id),
// escapeHtml() builds a detached div; textContent in, escaped HTML out.
createElement: () => {
const el = { innerHTML: "" };
Object.defineProperty(el, "textContent", {
set(v) {
el.innerHTML = String(v)
.replace(/&/g, "&amp;")
.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);
});
});

290
tests/walletDefects.test.js Normal file
View File

@@ -0,0 +1,290 @@
// Tests for the stored-state half of the non-master extended key problem.
//
// Refusing a non-master xprv at import time does nothing for a wallet that is
// already in storage: the import that created it ran before the refusal
// existed. Such a wallet used to sign for the wrong tree and now throws on the
// send screen instead. These tests pin down that it is named and explained in
// the wallet list, that nothing on the way there throws, and that a wallet
// imported from a real master key is untouched by any of it.
const { HDNodeWallet, Mnemonic } = require("ethers");
const wallet = require("../src/shared/wallet");
const {
walletDefect,
walletDefectHtml,
NON_MASTER_XPRV,
} = require("../src/shared/walletDefects");
// BIP-39 test vector phrase, published; never used for real funds.
const VECTOR_PHRASE =
"test test test test test test test test test test test junk";
function seedNode(phrase) {
return HDNodeWallet.fromSeed(Mnemonic.fromPhrase(phrase, "").computeSeed());
}
// The master (depth-0) key, which is what the import flow accepts today.
function masterXprv(phrase) {
return seedNode(phrase).extendedKey;
}
// The account-level (depth-3) key m/44'/60'/0'. A normal thing for a user to
// hold, and what the import flow used to accept.
function accountXprv(phrase) {
return seedNode(phrase).derivePath("m/44'/60'/0'").extendedKey;
}
// The wallet record the CURRENT import path writes for a master key: the
// neutered m/44'/60'/0'/0 node, four levels below a depth-0 key.
function healthyXprvWallet(name = "Master") {
const { xpub, firstAddress } = wallet.hdWalletFromXprv(
masterXprv(VECTOR_PHRASE),
);
return {
name,
type: "xprv",
xpub,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, balance: "0.0000" }],
};
}
// The wallet record the PRE-#210 import path wrote for an account-level key:
// the same four levels, but derived as a relative path *beneath* the key, so
// the stored xpub sits at depth 3 + 4 = 7. Built here the way the old code
// built it rather than by calling the module under test, which now refuses.
function brokenXprvWallet(name = "Imported xprv") {
const node = HDNodeWallet.fromExtendedKey(
accountXprv(VECTOR_PHRASE),
).derivePath("44'/60'/0'/0");
return {
name,
type: "xprv",
xpub: node.neuter().extendedKey,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [
{ address: node.deriveChild(0).address, balance: "0.0000" },
],
};
}
describe("the fixtures are what the two import paths actually produced", () => {
test("a master import stores a depth-4 xpub", () => {
expect(
HDNodeWallet.fromExtendedKey(healthyXprvWallet().xpub).depth,
).toBe(4);
});
test("the pre-fix account-level import stored a depth-7 xpub", () => {
expect(
HDNodeWallet.fromExtendedKey(brokenXprvWallet().xpub).depth,
).toBe(7);
});
});
describe("walletDefect", () => {
test("names the defect on a stored non-master xprv wallet", () => {
const defect = walletDefect(brokenXprvWallet());
expect(defect).not.toBeNull();
expect(defect.id).toBe(NON_MASTER_XPRV);
});
test("a depth-0 xprv wallet has no defect", () => {
expect(walletDefect(healthyXprvWallet())).toBeNull();
});
test("hd and key wallets are never assessed", () => {
expect(
walletDefect({ type: "hd", xpub: brokenXprvWallet().xpub }),
).toBe(null);
expect(walletDefect({ type: "key" })).toBeNull();
});
test("an xprv wallet whose xpub cannot be parsed makes no claim", () => {
// No basis to say the key was non-master, so nothing is asserted
// about it rather than guessing.
expect(walletDefect({ type: "xprv", xpub: "not-a-key" })).toBeNull();
expect(walletDefect({ type: "xprv" })).toBeNull();
});
test("nothing about the wallet record is modified by the check", () => {
const w = brokenXprvWallet();
const before = JSON.stringify(w);
walletDefect(w);
expect(JSON.stringify(w)).toBe(before);
});
});
describe("the explanatory copy", () => {
const defect = walletDefect(brokenXprvWallet());
test("every sentence of it is a full sentence", () => {
for (const text of [defect.heading, ...defect.paragraphs]) {
expect(text).toMatch(/^[A-Z]/);
expect(text.trimEnd()).toMatch(/\.$/);
}
});
test("it says what was derived wrongly and that these are not the standard addresses", () => {
const body = defect.paragraphs.join(" ");
expect(body).toContain("not a master key");
expect(body).toMatch(/standard path/);
});
test("it does not claim the funds are safe and does not claim a loss", () => {
const all = [defect.heading, ...defect.paragraphs].join(" ");
expect(all).not.toMatch(/\bsafe\b/i);
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
});
test("it says the wallet is not deleted and what the user can do", () => {
const body = defect.paragraphs.join(" ");
expect(body).toMatch(/until you delete it yourself/);
expect(body).toMatch(/recovery phrase/);
});
test("it uses the project's vocabulary", () => {
const all = [
defect.heading,
...defect.paragraphs,
defect.shortMessage,
].join(" ");
expect(all).not.toMatch(/seed phrase|mnemonic|passphrase/i);
expect(all).not.toMatch(/\baccounts?\b/i);
});
});
describe("walletDefectHtml", () => {
test("renders the heading and every paragraph for a defective wallet", () => {
const defect = walletDefect(brokenXprvWallet());
const html = walletDefectHtml(brokenXprvWallet());
expect(html).toContain(defect.heading);
for (const p of defect.paragraphs) {
expect(html).toContain(p);
}
});
test("renders nothing at all for a healthy wallet", () => {
expect(walletDefectHtml(healthyXprvWallet())).toBe("");
});
});
describe("the wallet list", () => {
let home;
let state;
beforeAll(() => {
global.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: { sendMessage: () => {} },
};
home = require("../src/popup/views/home");
state = require("../src/shared/state").state;
});
afterEach(() => {
state.wallets = [];
state.activeAddress = null;
});
test("a stored depth-3 xprv wallet renders the explanation", () => {
state.wallets = [brokenXprvWallet("Imported xprv")];
const html = home.walletListHtml();
expect(html).toContain(walletDefect(state.wallets[0]).heading);
expect(html).toContain("Imported xprv");
});
test("it does not offer to derive further addresses from that wallet", () => {
state.wallets = [brokenXprvWallet()];
expect(home.walletListHtml()).not.toContain("btn-add-address");
});
test("a normal depth-0 xprv wallet renders exactly as it did before", () => {
state.wallets = [healthyXprvWallet("Master")];
const html = home.walletListHtml();
expect(html).not.toContain(walletDefect(brokenXprvWallet()).heading);
expect(html).toContain("btn-add-address");
expect(html).toContain(state.wallets[0].addresses[0].address);
});
test("the defective wallet's notice does not bleed onto a healthy one", () => {
state.wallets = [brokenXprvWallet("Broken"), healthyXprvWallet("Fine")];
const html = home.walletListHtml();
const healthyPart = html.slice(html.indexOf("Fine"));
expect(html).toContain(walletDefect(state.wallets[0]).heading);
expect(healthyPart).not.toContain(
walletDefect(state.wallets[0]).heading,
);
expect(healthyPart).toContain("btn-add-address");
});
});
describe("no path throws an unhandled error for a defective wallet", () => {
test("address derivation from the stored xpub still works", () => {
// The stored xpub is at a non-standard depth but is a valid extended
// key; deriving from it is what the list render already does.
const w = brokenXprvWallet();
expect(() => wallet.deriveAddressFromXpub(w.xpub, 0)).not.toThrow();
expect(wallet.deriveAddressFromXpub(w.xpub, 0)).toBe(
w.addresses[0].address,
);
});
test("the wallet list renders without throwing", () => {
const { state } = require("../src/shared/state");
const home = require("../src/popup/views/home");
state.wallets = [brokenXprvWallet()];
expect(() => home.walletListHtml()).not.toThrow();
state.wallets = [];
});
test("signing refuses with the named defect rather than a bare failure", () => {
// getSignerForAddress is the backstop behind the UI gate. It must
// still refuse, and it must say why in a sentence the user can read.
let thrown = null;
try {
wallet.getSignerForAddress(
{ type: "xprv" },
0,
accountXprv(VECTOR_PHRASE),
);
} catch (e) {
thrown = e;
}
expect(thrown).not.toBeNull();
expect(thrown.message).toMatch(/master key/);
expect(thrown.message.trimEnd()).toMatch(/\.$/);
});
test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
0,
masterXprv(VECTOR_PHRASE),
);
expect(signer.address).toBe(healthyXprvWallet().addresses[0].address);
});
});