8 Commits
Author SHA1 Message Date
clawbot acb2577fec test: the test suite exits on its own, without --forceExit (closes #585)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Some tests left timers running when they ended, and --forceExit hid them; both test scripts drop it. Six background test files left the background's 20-second timer running while an approval was pending; each now stubs it for the whole file. Five popup test files left a flash message's two-second timer running; they now use fake timers, and a wait on real zero-delay timeouts became jest.advanceTimersByTimeAsync(0). Two ethers timers can still run for up to 250 ms after a test ends; they stop before jest checks for open handles.

Model: opus-5-5
2026-10-28 01:09:12 +01:00
clawbot a33a40a6fa build: say beside the install why yarn prints the workspaces warning (closes #595)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Yarn 1 prints "Workspaces can only be enabled in private projects." for each dependency whose package.json has a workspaces field without "private": true. Two do: eslint 10.8.1, and babel-plugin-istanbul 7.0.1, which jest pulls in. No update removes the field, and removing the warning would mean downgrading both below what this repo and jest require. A one-line comment beside the install in script/bootstrap and in the Dockerfile now names both packages and says why the warning stays; every Dockerfile install runs script/bootstrap, so one comment there is enough.

Model: opus-5-5
2026-10-28 00:28:27 +01:00
clawbot 5192419cac docs: README matches ConfirmTx's wrong-password message and fee-bound refusal (closes #610)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
README's ConfirmTx section quoted "Wrong password." for a wrong password; the screen says "That password is incorrect. Please try again.", and the README now quotes that. The other password screens describe their error without quoting it, and DeleteWallet already quoted it right. The fee-bound refusal no longer says the RPC endpoint always fills in the gas limit and fees at signing: for an ETH amount that Max filled in, they come from the screen's own fee estimate. The transition names `assertWithinCeilings()` and lists what it checks.

Model: opus-5-5
2026-10-28 00:05:20 +01:00
clawbot 41842b197e fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When the contract lookup on AddToken answered, the popup went to AddressDetail even after the user had opened another screen. It now goes there only while AddToken shows and has not been opened again since Add was pressed; AddToken counts its openings as Send does. The token is still added as before unless the network changed. An answer, found or failed, from an earlier opening leaves the screen and the status line alone, so a later lookup's "Looking up token..." stays.

Judgement call: a user who went Back to AddressDetail during the lookup sees the new token once that screen is drawn again.

Model: opus-5-5
2026-10-27 23:16:10 +01:00
clawbot 8998d6c661 fix: a refresh asked for while another runs is no longer dropped (closes #581)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
doRefreshAndRender() returned at once when a refresh was already running. An import asks for a refresh after its address scan, and that request was dropped when it came during a refresh, so the addresses the scan found showed 0.0000 until the next ten-second refresh. A request made through ctx while a refresh runs now leads to one more refresh once the running one ends, however many such requests came in. A tick of the ten-second timer during a refresh is still dropped, so the wallet asks the node no more often than before.

Model: opus-5-5
2026-10-27 20:14:09 +01:00
clawbot 08e47c39ad fix: a network switch in Settings keeps another page's token on the network it was added on (closes #692)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch in Settings filed under the new network a token or fraud contract that another page stored since this page's last save, because the switch's save writes only the fields this page changed. Before the switch, onChainSwitch() now takes the stored tracked tokens into the page's own list with takeStoredTrackedTokens(), matched by contract address, so a token either page added or removed stays as that page left it. The switch then files that list under the previous network. When the stored record is on another network, no tokens are taken. Fraud contracts are cleared on every switch, as before.

Model: opus-5-5
2026-10-27 18:05:01 +01:00
clawbot 7ba0a5ff75 fix: TransactionDetail drops a Blockscout answer for another transaction (closes #675)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TransactionDetail asks Blockscout for its transaction without waiting for the answer. If the user pressed Back and opened another transaction before Blockscout answered, the second screen showed the first transaction's block, network details, decoded details and raw data. loadFullTxDetails() now drops the answer once TransactionDetail shows another transaction; the same hash on another network counts as another transaction. An answer that lands while Settings is up is still drawn. Every failed request is still logged.

Model: opus-5-5
2026-10-27 15:30:40 +01:00
clawbot cd5951a4f8 fix: Settings never shows "Saved." for a URL the input no longer shows (closes #695)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The RPC and Blockscout "Save" buttons in Settings now store nothing when the input no longer shows the URL that was checked. When another page changes the network, "Use Blockscout" or "Check recipients on Etherscan", this page draws Settings again, and a redraw during the check puts this page's URL back in the input. Before this change, the typed URL was then stored and "Saved." appeared. A save refused because the input changed shows "The URL was not saved because it was changed during the check." A user's own edit during the check refuses the save too.

Model: opus-5-5
2026-10-27 14:34:50 +01:00
26 changed files with 966 additions and 56 deletions
+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
+20 -9
View File
@@ -2053,15 +2053,21 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's network's RPC endpoint, and the Etherscan label check asks that network's
explorer explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the - "Sign & Send" (correct password) when the gas limit or a fee is over its
total fee the RPC endpoint fills in at signing is over its bound in bound → nothing is signed; "The gas limit or fee the RPC endpoint quoted
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is for this transaction is more than this wallet will sign for, so it was not
signed; "The gas limit or fee the RPC endpoint quoted for this transaction sent. Please go back and try again." in its own box, no screen change. For
is more than this wallet will sign for, so it was not sent. Please go back an ETH amount that Send's "Max" filled in, the gas limit and fees are
and try again." in its own box, the same sentence for all three, no screen those of this screen's own fee estimate (see Amount above). For any other
change send, the RPC endpoint fills in the gas limit and fees at signing. Just
- "Sign & Send" (wrong password) → "Wrong password." on the password error before signing, `assertWithinCeilings()` (`src/shared/approvalVerify.js`)
line, no screen change refuses the send when any of these is over its bound:
- the gas limit
- each fee per gas the transaction sets
- the total fee: the gas limit times `maxFeePerGas`, or times `gasPrice`
when the transaction has no `maxFeePerGas`
- "Sign & Send" (wrong password) → "That password is incorrect. Please try
again." on the password error line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not screen with another address selected. When the selected wallet does not
@@ -2233,6 +2239,8 @@ view would leave a wallet one click from deletion.
**AddressDetail** **AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add", then the contract lookup answers while another screen shows, or
after AddToken was opened again → tracks the token, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
answers → nothing is tracked; "The active network changed before the answers → nothing is tracked; "The active network changed before the
lookup finished, so the token was not added." flash message, no screen lookup finished, so the token was not added." flash message, no screen
@@ -2279,6 +2287,9 @@ view would leave a wallet one click from deletion.
no user data and is made whether or not "Use Blockscout" is ticked. no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not - If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so saved and a flash message says so
- Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not
saved
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest --forceExit --maxWorkers=3", "test": "jest --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose", "test:verbose": "jest --maxWorkers=3 --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
+1
View File
@@ -118,6 +118,7 @@ ensure_yarn() {
fi fi
} }
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
install_js_deps() { install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
+27 -10
View File
@@ -65,7 +65,14 @@ function renderWalletList() {
const pageClosed = new AbortController(); const pageClosed = new AbortController();
window.addEventListener("pagehide", () => pageClosed.abort()); window.addEventListener("pagehide", () => pageClosed.abort());
// While a refresh runs, a request through ctx is not dropped. The request sets
// refreshAgain, and one more refresh runs once the running one ends. Several
// requests in that time lead to one more refresh, not several. An import asks
// for a refresh after its address scan. A refresh already running does not
// have the addresses the scan found
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
let refreshInFlight = false; let refreshInFlight = false;
let refreshAgain = false;
// The ten-second refresh startRefresh() starts, stopped when the popup moves to // The ten-second refresh startRefresh() starts, stopped when the popup moves to
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
@@ -89,7 +96,10 @@ let refreshTimer = null;
// user turned "Use Blockscout" off. So the save drops every token the user has // user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then. // not tracked if the stored setting is off by then.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) {
refreshAgain = true;
return;
}
refreshInFlight = true; refreshInFlight = true;
try { try {
await followStoredSettings(); await followStoredSettings();
@@ -132,28 +142,35 @@ async function doRefreshAndRender() {
}); });
renderWalletList(); renderWalletList();
} catch (e) { } catch (e) {
// Every call site fires this and walks away — the boot below, the ten // No caller waits for this function, so it must never reject. An
// second interval, and eight views through ctx — so it must never // unhandled rejection reports nothing. The save inside it reports its
// reject: an unhandled rejection is not a report of anything. The save // own failure through onSaveFailure() (see src/shared/state.js); what
// inside it reports its own failure through onSaveFailure() (see // is left here is a failed network round trip, which the next tick
// src/shared/state.js); what is left here is a failed network round // retries.
// trip, which the next tick retries.
log.errorf("popup: background refresh failed:", e); log.errorf("popup: background refresh failed:", e);
} finally { } finally {
refreshInFlight = false; refreshInFlight = false;
} }
if (refreshAgain) {
refreshAgain = false;
doRefreshAndRender();
}
} }
// Refreshes now and every ten seconds from then on. Called once a wallet // Refreshes now and every ten seconds from then on. Called once a wallet
// exists: by init() when the popup opens with one, and by AddWallet when it // exists: by init() when the popup opens with one, and by AddWallet when it
// adds one, which in a popup that opened on Welcome is the first // adds one, which in a popup that opened on Welcome is the first
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already // (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
// running it starts no second one, and on the recovery screen it does nothing. // refresh already started, it starts no second one. On the recovery screen it
// does nothing.
function startRefresh() { function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return; if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender(); doRefreshAndRender();
if (refreshTimer === null) { if (refreshTimer === null) {
refreshTimer = setInterval(doRefreshAndRender, 10000); // A tick that finds a refresh running is dropped.
refreshTimer = setInterval(() => {
if (!refreshInFlight) return doRefreshAndRender();
}, 10000);
} }
} }
+18
View File
@@ -5,7 +5,12 @@ const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before
// AddToken was last opened changes neither the screen nor the status line.
let addTokenScreenOpenings = 0;
function show() { function show() {
addTokenScreenOpenings++;
$("add-token-address").value = ""; $("add-token-address").value = "";
$("add-token-info").textContent = ""; $("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden"; $("add-token-info").style.visibility = "hidden";
@@ -55,6 +60,7 @@ function init(ctx) {
// The network the lookup asks, which Settings may switch away from // The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js. // before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId; const chainId = currentNetwork().chainId;
const opening = addTokenScreenOpenings;
try { try {
const info = await lookupTokenInfo( const info = await lookupTokenInfo(
contractAddr, contractAddr,
@@ -80,6 +86,17 @@ function init(ctx) {
}); });
await saveState(); await saveState();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
// If the lookup answers after AddToken was opened again, the
// token is still added, but the screen and the status line stay
// as they are.
if (addTokenScreenOpenings !== opening) return;
// The lookup is over, so a later Back onto AddToken shows no
// status.
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
// If the lookup answers while another screen shows, the token is
// still added but the screen does not change.
if (state.currentView !== "add-token") return;
// Pop the stack (back to address detail) and re-render it // Pop the stack (back to address detail) and re-render it
// so the newly added token is visible immediately. // so the newly added token is visible immediately.
if (state.viewStack.length > 0) { if (state.viewStack.length > 0) {
@@ -100,6 +117,7 @@ function init(ctx) {
? detail ? detail
: "Could not add the token.", : "Could not add the token.",
); );
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+18 -4
View File
@@ -339,17 +339,31 @@ function showNewSettings() {
// Another page may have stored a different URL since then. So `url` is set in // Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds // the stored record too. "Saved." then means that the stored record holds
// `url`. // `url`.
async function saveEndpoint(field, url, networkId) { //
// `input` is the id of the input that the user typed `url` in. When another
// page changes the network, "Use Blockscout" or "Check recipients on
// Etherscan", this page draws Settings again. A redraw during the endpoint
// check puts the URL from `state` back in the input. So if the input no longer
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
let refused = false; let refused = false;
let inputChanged = false;
await saveState((stored) => { await saveState((stored) => {
refused = refused =
state.networkId !== networkId || stored.networkId !== networkId; state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false; inputChanged = $(input).value.trim() !== url;
if (refused || inputChanged) return false;
state[field] = url; state[field] = url;
stored[field] = url; stored[field] = url;
}); });
if (refused) { if (refused) {
showFlash("The URL was not saved because the network changed."); showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
showFlash(
"The URL was not saved because it was changed during the check.",
);
} else { } else {
showFlash("Saved."); showFlash("Saved.");
} }
@@ -401,7 +415,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", url, net.id); await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
@@ -425,7 +439,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("blockscoutUrl", url, net.id); await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
+12
View File
@@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// Whether TransactionDetail still holds the transaction with `txHash` on
// `chainId`. Opening another transaction replaces the one TransactionDetail
// holds. A visit to Settings does not.
function holdsTransaction(txHash, chainId) {
const tx = state.viewData.tx;
return tx?.hash === txHash && tx?.chainId === chainId;
}
// Reads the block, network details and calldata from Blockscout, and decodes // Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not // the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped // asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown. // (src/shared/storedSettings.js). Either way none of them is shown.
//
// Blockscout can answer after the user has opened another transaction. That
// answer is dropped.
async function loadFullTxDetails(txHash, chainId) { async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
@@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!holdsTransaction(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
+18 -5
View File
@@ -53,18 +53,31 @@ function clearPopupCaches() {
// change. So the switch also resets these fields in the stored record: // change. So the switch also resets these fields in the stored record:
// //
// - every address's balance and token balances; // - every address's balance and token balances;
// - the time of the last balance refresh. // - the time of the last balance refresh;
// - the fraud contracts.
// //
// A balance that the background or another page stored since this page's last // A balance or a fraud contract that the background or another page stored
// save is then not kept under the new network // since this page's last save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653). // (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState, takeStoredTrackedTokens } = require("./state");
let net = null; let net = null;
await saveState((stored) => { await saveState((stored) => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) {
takeStoredTrackedTokens(stored);
}
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0; stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
for (const wallet of stored.wallets) { for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
addr.balance = "0"; addr.balance = "0";
+33 -7
View File
@@ -125,11 +125,12 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// The persisted fields as this page held them when its last loadState() // `baseline` holds the persisted fields as this page held them when its last
// finished, or when its last successful saveState() began, with any network // loadState() finished, or when its last successful saveState() began.
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
// saveState() diffs the live state against this to find only the fields THIS // this page has taken from the stored record since then. saveState() diffs the
// page changed since. // live state against `baseline` to find only the fields THIS page changed
// since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -442,8 +443,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// milder than the other two. // milder than the other two.
// //
// Every other persisted field stays a whole-field diff: // Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no // `fraudContracts`/`viewStack` are arrays of scalars with no per-element
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like // identity to merge by; a save diffs `trackedTokens` whole, and a switch in
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves // `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above, // together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset // but nothing in src/ ever writes an entry into it — it is only ever reset
@@ -615,6 +618,28 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(stored)); copyChainSwitchFields(baseline, structuredClone(stored));
} }
// Give this page the tracked tokens of `stored`, a record on this page's
// network that a save of this page has just read (onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`):
//
// - A token that another page tracked or removed since this page's last save
// is tracked or removed in `state` too.
// - A token that this page tracked or removed since then stays as this page
// has it.
//
// `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ.
function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity(
baseline.trackedTokens,
rawState.trackedTokens,
stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
);
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -661,6 +686,7 @@ module.exports = {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
takeStoredTrackedTokens,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+137
View File
@@ -0,0 +1,137 @@
// A contract lookup on AddToken can answer after the user has left AddToken.
// The token is still added, and the screen the user is on does not change
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
// still shows goes to AddressDetail. Driven through the real popup over a
// stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, then AddToken opened from it with
// TOKEN's contract address typed in.
async function openAddToken() {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = TOKEN.address;
return boot;
}
// Presses Add with a contract lookup that answers only when the function
// returned here is called. That function waits for Add to finish.
function addWithPendingLookup(boot) {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click("btn-add-token-confirm");
return async () => {
answer({
symbol: TOKEN.symbol,
decimals: TOKEN.decimals,
name: TOKEN.name,
});
await adding;
};
}
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await answerLookup();
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token...");
await boot.click("btn-settings");
await answerLookup();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("");
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
});
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
await answerFirstLookup();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("Looking up token...");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const { lookupTokenInfo } = require("../src/shared/balances");
let failFirstLookup;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((_, reject) => {
failFirstLookup = reject;
}),
);
const firstAdd = boot.click("btn-add-token-confirm");
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
failFirstLookup(new Error("lookup failed"));
await firstAdd;
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await answerLookup();
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).toContain("TST");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
+6 -1
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle. // show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
} }
// ------------------------------------------------------------ harness // ------------------------------------------------------------ harness
@@ -188,7 +188,11 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have. // handler to the buttons it drew, as the browser's fresh list would have.
let plusButton; let plusButton;
// showFlash() starts a timer that clears the message one or two seconds later.
// Each test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null; approvalDetails = null;
sent = []; sent = [];
mockNode.broadcast = []; mockNode.broadcast = [];
@@ -239,6 +243,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
+11 -12
View File
@@ -537,7 +537,18 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
}); });
@@ -1883,18 +1894,6 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1]; return bg.actionPopups[bg.actionPopups.length - 1];
} }
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => { test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
+11
View File
@@ -103,7 +103,18 @@ function deferred() {
return { promise, resolve }; return { promise, resolve };
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -56,7 +56,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -64,7 +64,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -76,7 +76,18 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve(); for (let i = 0; i < 60; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -57,7 +57,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
}); });
+11
View File
@@ -102,6 +102,17 @@ function makeDocument() {
// ------------------------------------------------------------ harness // ------------------------------------------------------------ harness
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function wallet(name, secret, addresses) { function wallet(name, secret, addresses) {
return { return {
type: "hd", type: "hd",
+157
View File
@@ -0,0 +1,157 @@
// While a refresh runs, a request through ctx is not dropped. One more refresh
// runs once the running one ends, however many were asked for in that time
// (https://git.eeqj.de/sneak/AutistMask/issues/581). A tick of the ten-second
// timer that finds a refresh running is dropped. Each test boots the real popup
// over a stored profile and holds the ten-second refresh open. `readChain()`
// sets each address's balance from `CHAIN`, as the real `refreshBalances()`
// sets it from the node.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const {
hdWalletFromMnemonic,
deriveAddressFromXpub,
} = require("../src/shared/wallet");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
// The first two addresses of PHRASE, both used on the chain.
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
const SECOND_ADDRESS = deriveAddressFromXpub(xpub, 1);
// The ETH balance the chain holds for each address, in ether.
const CHAIN = {
[ADDRESS]: "1.5",
[firstAddress]: "2.0",
[SECOND_ADDRESS]: "3.0",
};
afterEach(() => {
cleanupPopup();
});
// The balance stand-in for a refresh that returns.
async function readChain(wallets) {
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
addr.balance = CHAIN[addr.address];
}
}
}
// Boots the popup over a profile with one wallet. From then on,
// `refreshBalances()` never returns unless the test answers it. So an unwanted
// refresh is counted once and then waits, instead of starting more.
async function boot() {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementation(() => new Promise(() => {}));
return { page, refreshBalances };
}
// Starts the ten-second refresh and holds it open until finish() is called.
// The held refresh then writes the chain's balances. finish() resolves once
// the held refresh has saved them and drawn Home. The one refresh after the
// held one also returns.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances
.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
await readChain(wallets);
})
.mockImplementationOnce(readChain);
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Imports PHRASE on AddWallet. Its address scan finds the phrase's first two
// addresses.
async function importPhrase(page) {
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockImplementationOnce(async () => ({
addresses: [
{ address: firstAddress, index: 0 },
{ address: SECOND_ADDRESS, index: 1 },
],
nextIndex: 2,
}));
await page.click("btn-add-wallet-bottom");
await page.click("tab-mnemonic");
page.node("wallet-mnemonic").value = PHRASE;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// Imports the private key `key` on AddWallet.
async function importKey(page, key) {
await page.click("btn-add-wallet-bottom");
await page.click("tab-privkey");
page.node("import-private-key").value = key;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
test("addresses that an import finds during a refresh show their balances once one more refresh returns", async () => {
const { page } = await boot();
const refresh = await holdRefresh(page);
await importPhrase(page);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("flash-msg")).toBe("Found 2 addresses.");
await refresh.finish();
await page.settle();
const list = page.node("wallet-list").innerHTML;
expect(list).toContain("<span>2.0000</span>");
expect(list).toContain("<span>3.0000</span>");
});
test("several requests during a refresh lead to exactly one more refresh", async () => {
const { page, refreshBalances } = await boot();
const refresh = await holdRefresh(page);
const calls = refreshBalances.mock.calls.length;
await importKey(page, "0x" + "11".repeat(32));
await importKey(page, "0x" + "22".repeat(32));
await importPhrase(page);
expect(refreshBalances).toHaveBeenCalledTimes(calls);
await refresh.finish();
await page.settle();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
test("a tick of the ten-second timer during a refresh starts no other refresh", async () => {
const { page, refreshBalances } = await boot();
const refresh = await holdRefresh(page);
const calls = refreshBalances.mock.calls.length;
await page.tick();
await refresh.finish();
await page.settle();
expect(refreshBalances).toHaveBeenCalledTimes(calls);
});
+9 -1
View File
@@ -131,10 +131,14 @@ async function review(amount, token) {
async function confirm(amount, token) { async function confirm(amount, token) {
const txInfo = await review("0.5", token); const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount }); confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
} }
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
elements.clear(); elements.clear();
state.wallets = [ state.wallets = [
{ {
@@ -167,6 +171,10 @@ beforeEach(() => {
state.currentView = null; state.currentView = null;
}); });
afterEach(() => {
jest.useRealTimers();
});
describe.each([ describe.each([
["1e-3", "ETH"], ["1e-3", "ETH"],
["1e-3", "TOK"], ["1e-3", "TOK"],
+9 -1
View File
@@ -277,14 +277,18 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle. // show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
} }
function canSend() { function canSend() {
return !el("btn-confirm-send").disabled; return !el("btn-confirm-send").disabled;
} }
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
elements.clear(); elements.clear();
log.errorf.mockClear(); log.errorf.mockClear();
pageClosed = new AbortController(); pageClosed = new AbortController();
@@ -297,6 +301,10 @@ beforeEach(() => {
mockNode.broadcast = []; mockNode.broadcast = [];
}); });
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => { describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => { test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI); await refreshWith(BALANCE_WEI);
+120
View File
@@ -47,3 +47,123 @@ test.each([
expect(page.text("flash-msg")).toBe("Saved."); expect(page.text("flash-msg")).toBe("Saved.");
}, },
); );
// The user types a URL in Settings and taps "Save". While the check waits for
// the endpoint's answer, another page turns "Use Blockscout" on. This page
// follows that change and draws Settings again, with the stored URL in the
// input. The check then passes. The typed URL is not stored. The flash message
// says that the URL was changed during the check
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again during the check",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await Promise.all(saving);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// As above, but another write holds the lock. So this page's follow of the
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
// input still shows the typed URL. The save then asks for the lock, after the
// follow did. So the follow draws Settings again before the save compares the
// input.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again while the save waits for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.settle();
expect(page.value(input)).toBe(typedUrl);
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
+93 -4
View File
@@ -5,6 +5,9 @@
// a contract lookup that answers after one. Driven through the real popup over // a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in // a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js. // tests/coldWorkerChainSwitch.test.js.
//
// The tests of what another page stored before a switch in Settings cover a
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
const { const {
bootPopup, bootPopup,
@@ -178,9 +181,8 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
}); });
// Another popup page tracks DAI on Ethereum Mainnet after this page's last // Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch's save writes only the token lists that this page changed. // save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
// So DAI stays in `trackedTokens`, the active network's list. test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => {
const boot = await bootPopup(unversionedValidProfile()); const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]); expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", { boot.storage.write("autistmask", {
@@ -193,10 +195,97 @@ test("a token another page tracked before a switch in Settings stays in trackedT
const stored = boot.storage.read("autistmask"); const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia"); expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]); expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
// last save. This page still holds DAI. The switch to Sepolia does not store
// DAI under Ethereum Mainnet again.
test("a token another page removed before a switch in Settings stays removed", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]); expect(stored.networkTrackedTokens.mainnet).toEqual([]);
}); });
// Another popup page removes DAI after this page's last save. That page then
// adds DAI again, typing the contract address in lower case. The two pages'
// entries for DAI match, because contract addresses are compared ignoring
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
// Mainnet.
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page saves a switch to Sepolia after this page's last save.
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
fraudContracts: [NEW_TOKEN_ADDRESS],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.fraudContracts).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the // The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum // switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet. // Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
+202
View File
@@ -0,0 +1,202 @@
// TransactionDetail shows only details that Blockscout gave for the
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
//
// The screen asks Blockscout for its transaction and does not wait for the
// answer. So the user can press Back and open another transaction before
// Blockscout answers for the first. The first answer must then fill nothing on
// the second transaction's screen. An answer that lands while Settings is up
// must still fill TransactionDetail.
//
// Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
// opens it: the current screen is pushed, then transactionDetail.show() draws
// the transaction.
"use strict";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
// network with `chainId`.
function historyEntry(hash, chainId, symbol) {
return {
hash,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId,
};
}
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
// A transaction on Sepolia with the same hash as FIRST.
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
// The URL that asks Blockscout for `entry`, on the network it was sent on.
function requestUrl(entry) {
const blockscout =
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
return blockscout + "/transactions/" + entry.hash;
}
// What Blockscout reports for each transaction, by the URL that asks for it.
const ANSWERS = {
[requestUrl(FIRST)]: {
block_number: 21000001,
nonce: 4441,
fee: { value: "21000000000000" },
raw_input: "0x11111111",
method: "firstMethod",
},
[requestUrl(SECOND)]: {
block_number: 21000002,
nonce: 4442,
fee: { value: "42000000000000" },
raw_input: "0x22222222",
method: "secondMethod",
},
[requestUrl(FIRST_ON_SEPOLIA)]: {
block_number: 21000003,
nonce: 4443,
fee: { value: "63000000000000" },
raw_input: "0x33333333",
method: "sepoliaMethod",
},
};
// The text each answer puts on screen: its block, nonce, fee, method name and
// raw data. No text from one answer appears in another.
const FIRST_SHOWN = [
"21000001",
"4441",
"0.000021 ETH",
"firstMethod",
"0x11111111",
];
const SECOND_SHOWN = [
"21000002",
"4442",
"0.000042 ETH",
"secondMethod",
"0x22222222",
];
const FIRST_ON_SEPOLIA_SHOWN = [
"21000003",
"4443",
"0.000063 SepoliaETH",
"sepoliaMethod",
"0x33333333",
];
// Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) {
return [
"tx-detail-block",
"tx-detail-nonce",
"tx-detail-fee",
"tx-detail-calldata-action",
"tx-detail-rawdata",
]
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
.join("\n");
}
// Opens `entry` on TransactionDetail, as a history screen does.
function open(entry) {
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/transactionDetail").show(entry);
}
// The URLs of every request to Blockscout so far, in order.
function requestedUrls() {
return globalThis.fetch.mock.calls.map(([url]) => url);
}
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
// request at once from ANSWERS, except the request for `held`. Returns the
// page and answer(), which lets the answer for `held` land.
async function bootWithHeldRequest(held) {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
let answer;
const heldAnswer = new Promise((resolve) => {
answer = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
if (url === requestUrl(held)) await heldAnswer;
return { ok: true, status: 200, json: async () => ANSWERS[url] };
});
return { page, answer: () => answer() };
}
// The user opens `first`, whose request to Blockscout is held. Then the user
// presses Back and opens `second`. Then the answer for `first` lands. Returns
// the page.
async function firstAnswerLandsOnSecond(first, second) {
const { page, answer } = await bootWithHeldRequest(first);
open(first);
await page.settle();
await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]);
open(second);
await page.settle();
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
return page;
}
afterEach(() => {
cleanupPopup();
});
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
const drawn = drawnDetails(page);
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
const { page, answer } = await bootWithHeldRequest(FIRST);
open(FIRST);
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
answer();
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["transaction"]);
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
const drawn = drawnDetails(page);
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
});
+6
View File
@@ -357,6 +357,11 @@ describe("every way to a signature or the private key refuses a defective wallet
}); });
beforeEach(() => { beforeEach(() => {
// showFlash() starts a timer that clears the message two seconds
// later. Each test here runs on fake timers and drops them in
// afterEach, so that timer never outlives the test.
jest.useFakeTimers();
const broken = brokenXprvWallet(); const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand // A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check. // in for the defect check.
@@ -398,6 +403,7 @@ describe("every way to a signature or the private key refuses a defective wallet
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers();
state.wallets = []; state.wallets = [];
state.activeAddress = null; state.activeAddress = null;
state.selectedWallet = null; state.selectedWallet = null;