Compare commits
3 Commits
9d6e9eb752
...
781a7def17
| Author | SHA1 | Date | |
|---|---|---|---|
| 781a7def17 | |||
| afe6ddaea0 | |||
| 23712b53cb |
45
README.md
45
README.md
@@ -491,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
|
||||
@@ -601,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`)
|
||||
|
||||
@@ -693,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`)
|
||||
|
||||
@@ -816,7 +842,12 @@ of it.
|
||||
- "Hide fake tokens impersonating a known symbol" checkbox
|
||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||
- "Hide transactions from detected fraud contracts" checkbox
|
||||
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||
- "Hide dust transactions below N gwei" checkbox + threshold input. The
|
||||
threshold is plain decimal digits, a whole number of gwei, zero or
|
||||
greater (zero hides nothing). Anything else — a fraction, a negative,
|
||||
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
||||
is refused with a flash message and the field snaps back to the stored
|
||||
threshold, so a number the user did not type is never stored.
|
||||
- Allowed Sites: list with remove buttons
|
||||
- Denied Sites: list with remove buttons
|
||||
- About: project link, license, author, version, release date, and the
|
||||
|
||||
17
TODO.md
17
TODO.md
@@ -44,6 +44,23 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-12: The dust threshold field now explains a rejection instead of
|
||||
snapping back in silence, with the parse in a pure, unit-tested module that
|
||||
accepts plain decimal digits only — hex and exponent notation are refused
|
||||
rather than read as 16 and 1000
|
||||
([#233](https://git.eeqj.de/sneak/AutistMask/issues/233)).
|
||||
- 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
|
||||
|
||||
42
src/popup/dustThreshold.js
Normal file
42
src/popup/dustThreshold.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Parsing for the dust threshold field in Settings.
|
||||
//
|
||||
// Pure: no DOM, no state, so the accepted set can be unit tested directly
|
||||
// instead of through the settings view.
|
||||
//
|
||||
// Accepted input is plain decimal digits only, meaning a whole number of
|
||||
// gwei, zero or greater. Zero is a real setting: it hides nothing.
|
||||
//
|
||||
// Deliberately rejected, not coerced:
|
||||
// "" nothing to save
|
||||
// "-1" a negative threshold has no meaning
|
||||
// "1.5" fractional gwei is not a threshold the filter can use
|
||||
// "100 gwei" the unit is already printed beside the field
|
||||
// "0x10" hex, which Number() would silently read as 16
|
||||
// "1e3" exponent notation, which Number() would silently read as 1000
|
||||
//
|
||||
// The last two are the reason this is a digit test and not a Number() test.
|
||||
// Number() accepts both, and accepting them would put a number in the field
|
||||
// that the user did not type — the same silent substitution the visible
|
||||
// rejection message exists to end.
|
||||
|
||||
// Must render on ONE line of #flash-msg, whose reserved height
|
||||
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
|
||||
// wrap to two lines pushes the settings view down, which the No Layout Shift
|
||||
// policy forbids. Do not lengthen this without re-running the layout test in
|
||||
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
|
||||
const DUST_THRESHOLD_MESSAGE =
|
||||
"Please enter a whole number of gwei, zero or greater.";
|
||||
|
||||
// Returns the threshold in gwei, or null if the input is not one.
|
||||
function parseDustThresholdGwei(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!/^[0-9]+$/.test(trimmed)) return null;
|
||||
const val = Number(trimmed);
|
||||
// A run of digits long enough to exceed Number's exact integer range
|
||||
// would round on the way in, so it is not a threshold we can store.
|
||||
if (!Number.isSafeInteger(val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
module.exports = { DUST_THRESHOLD_MESSAGE, parseDustThresholdGwei };
|
||||
@@ -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();
|
||||
|
||||
@@ -22,6 +22,7 @@ const RESTORABLE_VIEWS = new Set([
|
||||
"settings-addtoken",
|
||||
"confirm-tx",
|
||||
"transaction",
|
||||
"wait-tx",
|
||||
"success-tx",
|
||||
"error-tx",
|
||||
]);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -2,7 +2,6 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
balanceLinesForAddress,
|
||||
addressDotHtml,
|
||||
addressTitle,
|
||||
@@ -27,8 +26,7 @@ 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
|
||||
@@ -321,81 +319,12 @@ function init(_ctx) {
|
||||
showFlash(defect.shortMessage);
|
||||
return;
|
||||
}
|
||||
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");
|
||||
// No pushCurrentView() here: exportPrivkey.show() can return
|
||||
// without navigating, so it does its own push.
|
||||
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
|
||||
});
|
||||
|
||||
$("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();
|
||||
});
|
||||
exportPrivkey.init();
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
hideError,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
@@ -461,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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
174
src/popup/views/exportPrivkey.js
Normal file
174
src/popup/views/exportPrivkey.js
Normal 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 };
|
||||
@@ -9,6 +9,10 @@ const {
|
||||
pushCurrentView,
|
||||
} = require("./helpers");
|
||||
const { applyTheme } = require("../theme");
|
||||
const {
|
||||
DUST_THRESHOLD_MESSAGE,
|
||||
parseDustThresholdGwei,
|
||||
} = require("../dustThreshold");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||
@@ -329,13 +333,14 @@ function init(ctx) {
|
||||
|
||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||
$("settings-dust-threshold").addEventListener("change", async () => {
|
||||
const raw = $("settings-dust-threshold").value.trim();
|
||||
const val = Number(raw);
|
||||
// 0 is accepted and means "hide nothing". Empty, negative,
|
||||
// fractional and non-numeric input is rejected outright rather than
|
||||
// coerced, and the field is put back to the stored threshold so it
|
||||
// never shows a value the wallet is not using.
|
||||
if (raw !== "" && Number.isInteger(val) && val >= 0) {
|
||||
const val = parseDustThresholdGwei($("settings-dust-threshold").value);
|
||||
// Rejected input is never coerced. The field is put back to the
|
||||
// stored threshold so it never shows a value the wallet is not
|
||||
// using, and the message says what the field wants so the snap-back
|
||||
// is explained rather than silent.
|
||||
if (val === null) {
|
||||
showFlash(DUST_THRESHOLD_MESSAGE);
|
||||
} else {
|
||||
state.dustThresholdGwei = val;
|
||||
await saveState();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
243
tests/dustThreshold.test.js
Normal file
243
tests/dustThreshold.test.js
Normal file
@@ -0,0 +1,243 @@
|
||||
// Tests for the dust threshold field in Settings (issue #233).
|
||||
//
|
||||
// Two halves: what the parse accepts, and what the settings view does with a
|
||||
// rejection. The view half runs against the real change handler with the DOM
|
||||
// helpers stubbed out, because the bug was not in the parse — it was that a
|
||||
// rejection said nothing.
|
||||
|
||||
const {
|
||||
DUST_THRESHOLD_MESSAGE,
|
||||
parseDustThresholdGwei,
|
||||
} = require("../src/popup/dustThreshold");
|
||||
|
||||
describe("parsing the dust threshold", () => {
|
||||
test("accepts a whole number of gwei", () => {
|
||||
expect(parseDustThresholdGwei("100000")).toBe(100000);
|
||||
expect(parseDustThresholdGwei("1")).toBe(1);
|
||||
});
|
||||
|
||||
// Zero is a real setting, not an empty field: it hides nothing.
|
||||
test("accepts zero", () => {
|
||||
expect(parseDustThresholdGwei("0")).toBe(0);
|
||||
});
|
||||
|
||||
test("accepts surrounding whitespace", () => {
|
||||
expect(parseDustThresholdGwei(" 250 ")).toBe(250);
|
||||
});
|
||||
|
||||
test("rejects an empty field", () => {
|
||||
expect(parseDustThresholdGwei("")).toBe(null);
|
||||
expect(parseDustThresholdGwei(" ")).toBe(null);
|
||||
});
|
||||
|
||||
test("rejects a negative threshold", () => {
|
||||
expect(parseDustThresholdGwei("-1")).toBe(null);
|
||||
});
|
||||
|
||||
// parseInt used to read this as 1, which is not what was typed.
|
||||
test("rejects a fractional value", () => {
|
||||
expect(parseDustThresholdGwei("1.5")).toBe(null);
|
||||
expect(parseDustThresholdGwei("1.0")).toBe(null);
|
||||
});
|
||||
|
||||
// parseInt used to read this as 100. The unit is printed beside the
|
||||
// field already.
|
||||
test("rejects a value carrying its unit", () => {
|
||||
expect(parseDustThresholdGwei("100 gwei")).toBe(null);
|
||||
});
|
||||
|
||||
// Number() reads this as 16. Storing 16 for a field that was told to
|
||||
// want a whole number of gwei would be the same silent substitution the
|
||||
// message exists to end.
|
||||
test("rejects hex notation", () => {
|
||||
expect(parseDustThresholdGwei("0x10")).toBe(null);
|
||||
});
|
||||
|
||||
// Number() reads this as 1000.
|
||||
test("rejects exponent notation", () => {
|
||||
expect(parseDustThresholdGwei("1e3")).toBe(null);
|
||||
});
|
||||
|
||||
test("rejects other non-numeric input", () => {
|
||||
expect(parseDustThresholdGwei("lots")).toBe(null);
|
||||
expect(parseDustThresholdGwei("+5")).toBe(null);
|
||||
expect(parseDustThresholdGwei("Infinity")).toBe(null);
|
||||
expect(parseDustThresholdGwei(undefined)).toBe(null);
|
||||
expect(parseDustThresholdGwei(5)).toBe(null);
|
||||
});
|
||||
|
||||
// Beyond 2^53 the digits would round on the way in, so the stored
|
||||
// threshold would not be the one typed.
|
||||
test("rejects a value too large to hold exactly", () => {
|
||||
expect(parseDustThresholdGwei("9007199254740993")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rejection message", () => {
|
||||
// README, Language & Labeling: error messages are full sentences.
|
||||
test("is a full sentence naming the constraint", () => {
|
||||
expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/);
|
||||
expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei");
|
||||
expect(DUST_THRESHOLD_MESSAGE).toContain("zero or greater");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the flash line the message is shown in", () => {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const POPUP_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// This asserts only that the reservation exists in the markup. It does
|
||||
// NOT and CANNOT assert that the message fits inside it: jest runs on
|
||||
// the node environment here, with no layout engine, so every rendered
|
||||
// height is zero. An earlier version of this block claimed to pin the
|
||||
// No Layout Shift policy with this regex, and it passed at any message
|
||||
// length, including one that wrapped to two lines and pushed the
|
||||
// settings view down 12px.
|
||||
//
|
||||
// The assertion that actually measures — empty line vs. the message,
|
||||
// real Chromium, documented 360x600 popup — is
|
||||
// "a rejected dust threshold shifts no layout (#233)" in
|
||||
// tests/e2e/run.js, run by make test-e2e. It is not in make check
|
||||
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
|
||||
// suite does not fit; run it before changing the wording.
|
||||
test("reserves its height in the markup", () => {
|
||||
const flashLine = POPUP_HTML.match(
|
||||
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
||||
);
|
||||
expect(flashLine).not.toBeNull();
|
||||
expect(flashLine[1]).toMatch(/min-h-\[/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the settings view on a change to the field", () => {
|
||||
let elements;
|
||||
let flashes;
|
||||
let saves;
|
||||
let state;
|
||||
|
||||
// A stand-in for one DOM node: enough of an element for init() to set
|
||||
// properties on it and hang listeners off it.
|
||||
function fakeElement() {
|
||||
return {
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
href: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
listeners: {},
|
||||
addEventListener(event, handler) {
|
||||
this.listeners[event] = handler;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadSettingsView() {
|
||||
elements = {};
|
||||
flashes = [];
|
||||
saves = 0;
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock("../src/popup/views/helpers", () => ({
|
||||
$: (id) => (elements[id] ||= fakeElement()),
|
||||
showView: () => {},
|
||||
updateDebugBanner: () => {},
|
||||
showFlash: (msg) => flashes.push(msg),
|
||||
escapeHtml: (s) => s,
|
||||
flashCopyFeedback: () => {},
|
||||
goBack: () => {},
|
||||
pushCurrentView: () => {},
|
||||
onViewLeave: () => {},
|
||||
VIEWS: [],
|
||||
}));
|
||||
|
||||
state = require("../src/shared/state").state;
|
||||
state.dustThresholdGwei = 100000;
|
||||
|
||||
const settings = require("../src/popup/views/settings");
|
||||
settings.init({});
|
||||
return elements["settings-dust-threshold"];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.chrome = {
|
||||
runtime: { sendMessage: () => {} },
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({}),
|
||||
set: async () => {
|
||||
saves++;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.dontMock("../src/popup/views/helpers");
|
||||
delete globalThis.chrome;
|
||||
});
|
||||
|
||||
async function change(field, typed) {
|
||||
field.value = typed;
|
||||
await field.listeners.change();
|
||||
}
|
||||
|
||||
test("a valid value is stored and says nothing", async () => {
|
||||
const field = loadSettingsView();
|
||||
|
||||
await change(field, "250");
|
||||
|
||||
expect(state.dustThresholdGwei).toBe(250);
|
||||
expect(field.value).toBe(250);
|
||||
expect(flashes).toEqual([]);
|
||||
expect(saves).toBe(1);
|
||||
});
|
||||
|
||||
test("a rejected value shows the message and is not stored", async () => {
|
||||
const field = loadSettingsView();
|
||||
|
||||
await change(field, "1.5");
|
||||
|
||||
expect(state.dustThresholdGwei).toBe(100000);
|
||||
expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]);
|
||||
expect(saves).toBe(0);
|
||||
});
|
||||
|
||||
// The snap-back is the behaviour the message explains, so it stays.
|
||||
test("a rejected value still resyncs the field to what is stored", async () => {
|
||||
const field = loadSettingsView();
|
||||
|
||||
await change(field, "100 gwei");
|
||||
|
||||
expect(field.value).toBe(100000);
|
||||
});
|
||||
|
||||
test("every rejected notation gets the same one message", async () => {
|
||||
for (const typed of ["", "-1", "1.5", "100 gwei", "0x10", "1e3"]) {
|
||||
const field = loadSettingsView();
|
||||
|
||||
await change(field, typed);
|
||||
|
||||
expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]);
|
||||
expect(state.dustThresholdGwei).toBe(100000);
|
||||
}
|
||||
});
|
||||
|
||||
test("zero is accepted, not treated as an empty field", async () => {
|
||||
const field = loadSettingsView();
|
||||
|
||||
await change(field, "0");
|
||||
|
||||
expect(state.dustThresholdGwei).toBe(0);
|
||||
expect(flashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
130
tests/e2e/run.js
130
tests/e2e/run.js
@@ -19,6 +19,7 @@ const {
|
||||
visible,
|
||||
} = require("./harness");
|
||||
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||
|
||||
const TEST_TIMEOUT_MS = 120000;
|
||||
|
||||
@@ -398,6 +399,135 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
||||
assertWiped(st, env.phrase, "after reopening the popup");
|
||||
});
|
||||
|
||||
// ------------------------------------------------ dust threshold (#233)
|
||||
|
||||
// The popup size README documents the UI as designed for. Pages in this
|
||||
// context otherwise get Playwright's 1280x720 default, at which the flash
|
||||
// line has room for any plausible message and never wraps — measuring
|
||||
// there would pass for every string and prove nothing.
|
||||
const POPUP_VIEWPORT = { width: 360, height: 600 };
|
||||
|
||||
// Everything below the flash line that must not move when it fills, plus
|
||||
// the height of the line itself. Runs in the page.
|
||||
//
|
||||
// Positions are in document coordinates, not viewport coordinates:
|
||||
// tabbing out of the field to fire "change" scrolls the popup, and a
|
||||
// getBoundingClientRect().top read across that scroll reports a thousand
|
||||
// pixels of movement that is the scroll, not a layout shift.
|
||||
function measureFlashLine() {
|
||||
const top = (id) =>
|
||||
document.getElementById(id).getBoundingClientRect().top +
|
||||
window.scrollY;
|
||||
return {
|
||||
text: document.getElementById("flash-msg").textContent,
|
||||
flashHeight: document
|
||||
.getElementById("flash-msg")
|
||||
.getBoundingClientRect().height,
|
||||
settingsTop: top("view-settings"),
|
||||
fieldTop: top("settings-dust-threshold"),
|
||||
};
|
||||
}
|
||||
|
||||
// Polling one evaluate() rather than waitForFunction() plus a second
|
||||
// round trip to measure: showFlash() clears the line again after 2s, and
|
||||
// measuring in a separate call can land after that and read an empty
|
||||
// line — which would pass however long the message is. Here the text
|
||||
// check and the geometry come from the same page task, so what is
|
||||
// measured is always the filled line. Missing the 2s window entirely
|
||||
// throws; it cannot go green.
|
||||
async function waitForFilledFlashLine(page) {
|
||||
const deadline = Date.now() + 15000;
|
||||
for (;;) {
|
||||
const m = await page.evaluate(measureFlashLine);
|
||||
if (m.text.length > 0) return m;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("the flash line never filled");
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
// README, No Layout Shift: the rejection message goes into #flash-msg,
|
||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
|
||||
// the space is not enough on its own — a message too long for one line
|
||||
// wraps and pushes everything below it down anyway, which is what the
|
||||
// first version of this change shipped: 75 characters, 32px, the settings
|
||||
// view and the threshold field 12px lower than with an empty line.
|
||||
//
|
||||
// So this measures rather than inspects markup. It is the only assertion
|
||||
// in the repo that can see the wording grow: the unit suite runs on the
|
||||
// node environment with no layout engine, where every height is zero (see
|
||||
// the note in tests/dustThreshold.test.js). Lengthen
|
||||
// DUST_THRESHOLD_MESSAGE past one line and this test goes red.
|
||||
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
const page = await openPopup(env.ctx, env.popupUrl);
|
||||
try {
|
||||
await page.setViewportSize(POPUP_VIEWPORT);
|
||||
await openSettings(page);
|
||||
|
||||
const before = await page.evaluate(measureFlashLine);
|
||||
assert(
|
||||
before.text === "",
|
||||
"the flash line was not empty at the baseline measurement: " +
|
||||
JSON.stringify(before.text),
|
||||
);
|
||||
|
||||
// "change" fires on blur, not on typing, so fill() alone is not
|
||||
// enough — it only dispatches "input".
|
||||
await page.fill("#settings-dust-threshold", "1.5");
|
||||
await page.locator("#settings-dust-threshold").press("Tab");
|
||||
|
||||
const after = await waitForFilledFlashLine(page);
|
||||
|
||||
// Printed pass or fail: the numbers are the evidence, and a
|
||||
// silent assertion would leave the reader taking this on trust.
|
||||
console.log(
|
||||
"# dust threshold flash: " +
|
||||
after.text.length +
|
||||
" chars, line height " +
|
||||
before.flashHeight +
|
||||
" -> " +
|
||||
after.flashHeight +
|
||||
", view-settings top " +
|
||||
before.settingsTop +
|
||||
" -> " +
|
||||
after.settingsTop +
|
||||
", field top " +
|
||||
before.fieldTop +
|
||||
" -> " +
|
||||
after.fieldTop,
|
||||
);
|
||||
|
||||
assert(
|
||||
after.text === DUST_THRESHOLD_MESSAGE,
|
||||
"the field flashed something other than DUST_THRESHOLD_MESSAGE: " +
|
||||
JSON.stringify(after.text),
|
||||
);
|
||||
assert(
|
||||
after.flashHeight === before.flashHeight,
|
||||
"the message does not fit the reserved line: " +
|
||||
before.flashHeight +
|
||||
"px empty vs " +
|
||||
after.flashHeight +
|
||||
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
|
||||
);
|
||||
assert(
|
||||
after.settingsTop === before.settingsTop,
|
||||
"the settings view moved " +
|
||||
(after.settingsTop - before.settingsTop) +
|
||||
"px when the message appeared",
|
||||
);
|
||||
assert(
|
||||
after.fieldTop === before.fieldTop,
|
||||
"the dust threshold field moved " +
|
||||
(after.fieldTop - before.fieldTop) +
|
||||
"px when the message appeared",
|
||||
);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- runner
|
||||
|
||||
async function main() {
|
||||
|
||||
331
tests/exportPrivkey.test.js
Normal file
331
tests/exportPrivkey.test.js
Normal 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
482
tests/txStatus.test.js
Normal file
@@ -0,0 +1,482 @@
|
||||
// Lifecycle tests for the post-broadcast transaction status views
|
||||
// (src/popup/views/txStatus.js).
|
||||
//
|
||||
// The bug these pin down: the receipt poll rendered both outcomes on the tick
|
||||
// that crossed the 60-second deadline, so a confirmed transaction was replaced
|
||||
// by "not confirmed within 60 seconds" — the user is told their transaction
|
||||
// failed when it succeeded. The same shape applies to any callback that
|
||||
// outlives its wait: a receipt lookup still in flight when the view is left
|
||||
// must not render over whatever replaced it.
|
||||
//
|
||||
// Fake timers make the race deterministic: the receipt promise is already
|
||||
// resolved when the deadline tick runs, so in the unfixed code showSuccess()
|
||||
// is always followed by showError() on that tick.
|
||||
//
|
||||
// No network: getProvider is mocked at the module boundary and there is no
|
||||
// jsdom in this repo, so the handful of DOM calls these views make are served
|
||||
// by the stub below.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
const mockReceiptLookup = jest.fn();
|
||||
jest.mock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
|
||||
refreshBalances: jest.fn(),
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal DOM. Every element is created on demand and remembered by id, so a
|
||||
// test can read back what a view wrote into it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const elements = new Map();
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set(["view", "hidden"]);
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
style: {},
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
|
||||
},
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
remove: () => {},
|
||||
prepend: () => {},
|
||||
};
|
||||
// Views reach for .parentElement to hide whole sections.
|
||||
Object.defineProperty(el, "parentElement", {
|
||||
get: () => getElement(id + "-parent"),
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function getElement(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeElement(id));
|
||||
return elements.get(id);
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById: (id) => getElement(id),
|
||||
// escapeHtml() builds a detached div; textContent in, escaped HTML out.
|
||||
createElement: () => {
|
||||
const el = { innerHTML: "" };
|
||||
Object.defineProperty(el, "textContent", {
|
||||
set(v) {
|
||||
el.innerHTML = String(v)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
},
|
||||
});
|
||||
return el;
|
||||
},
|
||||
body: { prepend: () => {} },
|
||||
addEventListener: () => {},
|
||||
};
|
||||
|
||||
global.window = { location: { search: "" } };
|
||||
|
||||
const stored = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set: (obj) => {
|
||||
Object.assign(stored, obj);
|
||||
return Promise.resolve();
|
||||
},
|
||||
get: () => Promise.resolve(stored),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const TX_INFO = {
|
||||
to: RECIPIENT,
|
||||
amount: "0.0050",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
};
|
||||
|
||||
// True when a view element is not hidden.
|
||||
function visible(view) {
|
||||
return !getElement("view-" + view).classList.contains("hidden");
|
||||
}
|
||||
|
||||
function waitStatusText() {
|
||||
return getElement("wait-tx-status").textContent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
||||
elements.clear();
|
||||
mockReceiptLookup.mockReset();
|
||||
state.wallets = [];
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
txStatus.init({ doRefreshAndRender: jest.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("WaitTx receipt/timeout race", () => {
|
||||
test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => {
|
||||
// No receipt for the first five polls; the sixth — the tick at
|
||||
// t=60s, which is also the timeout deadline — returns one.
|
||||
mockReceiptLookup
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue({ blockNumber: 21000000 });
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(visible("success-tx")).toBe(true);
|
||||
expect(visible("error-tx")).toBe(false);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(state.viewData.blockNumber).toBe(21000000);
|
||||
expect(state.viewData.message).toBeUndefined();
|
||||
|
||||
// And nothing is left running to undo it.
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(300000);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("a genuine timeout still shows ErrorTx with the hash", async () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(visible("error-tx")).toBe(true);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(
|
||||
/not confirmed within 60 seconds/,
|
||||
);
|
||||
expect(state.viewData.hash).toBe(TX_HASH);
|
||||
// The hash section carries the hash and the etherscan link.
|
||||
expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH);
|
||||
expect(getElement("error-tx-hash").innerHTML).toContain(
|
||||
"/tx/" + TX_HASH,
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("a receipt still in flight when the view is left does not render over it", async () => {
|
||||
let resolveReceipt;
|
||||
mockReceiptLookup.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolveReceipt = r;
|
||||
}),
|
||||
);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||
|
||||
// User leaves the wait (popup navigation / teardown) while the
|
||||
// lookup is outstanding, then the lookup finally answers.
|
||||
txStatus.endWait();
|
||||
state.currentView = "main";
|
||||
resolveReceipt({ blockNumber: 21000000 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(state.currentView).toBe("main");
|
||||
expect(visible("success-tx")).toBe(false);
|
||||
});
|
||||
|
||||
test("no timer survives the view being left", async () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
txStatus.endWait();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(120000);
|
||||
expect(mockReceiptLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WaitTx persistence across popup close", () => {
|
||||
test("restoreWait resumes the poll with the deadline running from broadcast", async () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
expect(state.viewData.pendingWait.hash).toBe(TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
|
||||
// Popup closes: timers die with the page.
|
||||
txStatus.endWait();
|
||||
|
||||
// 45 seconds pass with the popup shut, then it is reopened.
|
||||
jest.advanceTimersByTime(45000);
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
// Elapsed is counted from the broadcast, not from the reopen.
|
||||
expect(waitStatusText()).toBe("Waiting for confirmation... 45s");
|
||||
// The immediate poll on resume has already run.
|
||||
await Promise.resolve();
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The deadline is 15 seconds away, not 60.
|
||||
await jest.advanceTimersByTimeAsync(20000);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
});
|
||||
|
||||
test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => {
|
||||
// A wait resumed after the deadline has already passed: the first
|
||||
// poll is immediate and past 60s, so a thrown lookup must not be
|
||||
// read as "no receipt". It means "no answer this tick" — keep
|
||||
// polling, because the transaction may well have confirmed.
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
txStatus.endWait();
|
||||
|
||||
// Ten minutes with the popup shut, then it is reopened and the
|
||||
// first receipt lookup fails transiently.
|
||||
jest.advanceTimersByTime(600000);
|
||||
mockReceiptLookup.mockReset();
|
||||
mockReceiptLookup
|
||||
.mockRejectedValueOnce(new Error("rpc unavailable"))
|
||||
.mockResolvedValue({ blockNumber: 21000000 });
|
||||
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The wait is still alive: no timeout was declared off one error.
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
expect(visible("error-tx")).toBe(false);
|
||||
expect(state.currentView).toBe("wait-tx");
|
||||
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
// And the next tick answers, so the confirmed transaction is
|
||||
// reported as confirmed.
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(state.viewData.blockNumber).toBe(21000000);
|
||||
});
|
||||
|
||||
test("a lookup returning null past the deadline still times out", async () => {
|
||||
// The counterpart to the test above: the deadline must still fire
|
||||
// when the lookup actually answers "no receipt".
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
txStatus.endWait();
|
||||
|
||||
jest.advanceTimersByTime(600000);
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(
|
||||
/not confirmed within 60 seconds/,
|
||||
);
|
||||
});
|
||||
|
||||
test("restoreWait reports nothing to resume when no wait is persisted", () => {
|
||||
state.viewData = {};
|
||||
expect(txStatus.restoreWait()).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => {
|
||||
for (const bad of [
|
||||
{ hash: TX_HASH, broadcastTime: Date.now() },
|
||||
{ hash: TX_HASH, txInfo: TX_INFO },
|
||||
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" },
|
||||
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN },
|
||||
{ hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() },
|
||||
// An object that merely lacks a field startWait() dereferences
|
||||
// is the shape that actually escaped: txInfo.to reaches
|
||||
// addressTitle(), which calls address.toLowerCase(). typeof []
|
||||
// is "object", so an array passes an object check.
|
||||
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
|
||||
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
|
||||
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
|
||||
// Otherwise complete but for a non-string `to`: only the `to`
|
||||
// check rejects this one, and without it addressTitle() throws
|
||||
// out of restoreView().
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { to: 42, amount: "0.0050" },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
// Otherwise complete but an array: only Array.isArray() rejects
|
||||
// it, since typeof [] is "object" and the fields are present.
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { to: RECIPIENT },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
]) {
|
||||
state.viewData = { pendingWait: bad };
|
||||
expect(txStatus.restoreWait()).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("restoreWait resumes a wait whose recipient is the empty string", () => {
|
||||
// The shape a contract-deployment approval persists: approval.js
|
||||
// writes `to: toAddr || ""`, and showWait() renders it without
|
||||
// complaint. Validation must not be stricter than the live path, or
|
||||
// that wait is silently abandoned on every popup open.
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
state.viewData = {
|
||||
pendingWait: {
|
||||
hash: TX_HASH,
|
||||
txInfo: { ...TX_INFO, to: "" },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
};
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WaitTx against an RPC that never answers", () => {
|
||||
test("a permanently failing lookup ends the wait instead of polling forever", async () => {
|
||||
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
|
||||
// Six consecutive failures is 60 seconds at the 10s cadence — the
|
||||
// same patience as the confirmation deadline.
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(visible("wait-tx")).toBe(false);
|
||||
// The user is told what actually happened: the lookup failed. It is
|
||||
// not the same fact as "the transaction did not confirm".
|
||||
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
||||
expect(state.viewData.hash).toBe(TX_HASH);
|
||||
|
||||
// Nothing is left running, and nothing is left to resume onto.
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(state.viewData.pendingWait).toBeUndefined();
|
||||
|
||||
const calls = mockReceiptLookup.mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(3600000);
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(calls);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
});
|
||||
|
||||
test("an answered lookup clears the failure count, so the bound is on consecutive failures", async () => {
|
||||
// The bound counts failures in a row, not failures in total: a
|
||||
// flaky RPC that keeps answering in between must not accumulate its
|
||||
// way to a false "network unreachable".
|
||||
//
|
||||
// Polls 1-5 (t=10s..50s) alternate reject / null, so three fail and
|
||||
// the last answer resets the count at poll 4. From poll 6 on every
|
||||
// lookup fails. Six in a row is then poll 10, at t=100s. A counter
|
||||
// that never reset would have reached six at poll 8, t=80s, so the
|
||||
// window between those two is what this test occupies.
|
||||
mockReceiptLookup.mockImplementation(() => {
|
||||
const n = mockReceiptLookup.mock.calls.length;
|
||||
if (n <= 5 && n % 2 === 0) return Promise.resolve(null);
|
||||
return Promise.reject(new Error("flaky"));
|
||||
});
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
|
||||
// t=90s: eight failures in total, five of them in a row. A
|
||||
// cumulative counter has long since fired; a consecutive one has not.
|
||||
await jest.advanceTimersByTimeAsync(90000);
|
||||
expect(state.currentView).toBe("wait-tx");
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
// t=100s: the sixth in a row.
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||
// No lookup ever answered "no receipt" past the deadline, so this
|
||||
// is not the timeout and must not be reported as one.
|
||||
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("a resumed wait against a dead RPC also terminates", async () => {
|
||||
// The reopen path is the one that made this unbounded: the wait is
|
||||
// persisted, so without a bound every popup open resumes it forever.
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
txStatus.endWait();
|
||||
|
||||
jest.advanceTimersByTime(3600000);
|
||||
mockReceiptLookup.mockReset();
|
||||
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
||||
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(state.viewData.pendingWait).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||
// the resume silently — the tests above call restoreWait() directly and
|
||||
// would all still pass. This pins the membership. Mirrors the exclusion
|
||||
// assertions in tests/showPhrase.test.js.
|
||||
test("wait-tx is restorable", () => {
|
||||
expect(RESTORABLE_VIEWS.has("wait-tx")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user