Author SHA1 Message Date
sneak df0de00821 fix: closing a transaction or signature approval window leaves the request pending (closes #673)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The browser can close the approval window when the user clicks away from it,
for example to copy the password from a password manager. Closing it used to
reject the request with 4001. Now the request stays pending and the site gets
no answer. Until it is answered, the toolbar popup opens the oldest such
approval. A worker that starts holds no approvals, so it points the toolbar
popup back at the wallet.

Reject now closes its window only after the background has the rejection. Sent
as the window closed, the rejection could be lost, which the old window-close
rejection hid.

Site-connection and network-switch windows still reject the request when
closed.

Model: opus-5-5
2026-10-26 21:18:24 +00:00
35 changed files with 123 additions and 1018 deletions
+20 -56
View File
@@ -512,10 +512,9 @@ window of its own, and the password is required to be absent from every message
the approval window sends to the background — with the message that would carry
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). Closing a transaction
or signature approval window is required to leave the dApp's request unanswered.
The suite then opens that approval at the URL the toolbar button holds, and
answers it there.
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). A transaction or
signature approval whose window is closed is required to leave the page without
an answer. The suite then answers it from the page the toolbar button opens.
The limits of that coverage and of the rest of the Chrome suite, none of them
papered over:
@@ -1493,9 +1492,7 @@ send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on. While that setting is on, a token just added reads
`quantity unknown` at an address with no entry for it, until a refresh has read
its balance at every address (see AddToken).
zero balance" is on.
#### Stored state and its version
@@ -1508,12 +1505,11 @@ wrote last, the record says which build's shape it is in.
Each write reads the record, changes it and writes it back, holding a lock from
the read to the write: the browser's own (`navigator.locks`), taken through
`withLock()` in `src/shared/browserApi.js`. Every page of the extension and its
background share it, except a page in a Firefox private window, which has locks
of its own. `manifest/firefox.json` sets `"incognito": "not_allowed"`, so such a
page cannot read or write extension storage. In Chrome, a page opened from an
incognito window still runs outside incognito and shares the lock. The popup's
`saveState()`, the background's `updateState()` and the StateRecovery reset all
take it, so no write lands between another write's read and its write, where it
background share it, except a page in a Firefox private window, which Firefox
gives locks of its own (see https://git.eeqj.de/sneak/AutistMask/issues/646).
The popup's `saveState()`, the background's `updateState()` and the
StateRecovery reset all take it, so among the pages and the background that
share it, no write lands between another write's read and its write, where it
would be lost. Chrome has it from version 69, older than any Chrome that runs a
Manifest V3 extension, in pages and in the service worker; Firefox from version
96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
@@ -1679,13 +1675,6 @@ network switch (below). That popup then redraws its top screen as a reopened
popup would. A screen that a reopened popup cannot render, such as Send, is
replaced by Home.
The popup refreshes balances every ten seconds while it is open
(`doRefreshAndRender()` in `src/popup/index.js`). Each refresh that returns
draws the wallet list again. When AddressDetail or AddressToken is up, the
refresh also draws that screen's USD total and balance lines again, but not its
transaction list. A refresh draws no other screen again, DeleteAddress and its
balance lines included.
A network switch leaves every screen on the page showing the previous network.
The user makes it in Settings, or approves a site's request and the background
saves it. Any other popup open meanwhile takes the switch from the stored
@@ -1809,20 +1798,18 @@ view would leave a wallet one click from deletion.
without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer.
- The password needs at least 12 characters. With "Allow weak passwords"
ticked in Settings, one character is enough.
- "Import" button, with the error line beside it so that it adds no height
to a screen whose button already starts near the bottom of the popup
- **Transitions**:
- "Import" with a valid entry and a matching password that is long enough →
creates the wallet, clears the navigation stack, and → **Home**. When no
address is active yet, as on the first wallet, the new wallet's first
address becomes the active one. The phrase and xprv modes then scan for
further used addresses and report the count as a flash message. When the
scan finds two or more used addresses, they replace the wallet's list,
which then holds the first address only if it was used. If the first
address was active and is not in the new list, the new list's first
address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
- "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and →
**Home**. When no address is active yet, as on the first wallet, the new
wallet's first address becomes the active one. The phrase and xprv modes
then scan for further used addresses and report the count as a flash
message. When the scan finds two or more used addresses, they replace the
wallet's list, which then holds the first address only if it was used. If
the first address was active and is not in the new list, the new list's
first address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast.
- "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change
@@ -2242,15 +2229,7 @@ view would leave a wallet one click from deletion.
- "Add" button
- **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**. Until a balance refresh on the token's network has read
the token's balance at every address, its line never reads `0.0000`. The
line reads `quantity unknown` instead, or is left out while "Show tracked
tokens with zero balance" is off. Where Blockscout already listed a
holding of the token, the line shows that holding. Until that refresh, the
tracked token's record carries `balanceNotFetched`, so the line still
reads `quantity unknown` after the popup closes and opens again. A refresh
that returns draws AddressDetail's balances again (see Navigation), so the
fetched balance shows without the screen being reopened
**AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup
@@ -2267,10 +2246,6 @@ view would leave a wallet one click from deletion.
- Wallets: one row per wallet with its name (tap to rename inline), a
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button
- Passwords:
- "Allow weak passwords" checkbox, unticked until the user ticks it
- A sentence saying the setting lets the user choose a password shorter
than 12 characters on AddWallet
- Tracked Tokens: one row per token tracked on the active network with an
`[x]` remove button, plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
@@ -2551,9 +2526,7 @@ view would leave a wallet one click from deletion.
add path
- **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change). Until a refresh has read
the token's balance at every address, its lines read as after "Add" on
AddToken
"Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the
@@ -2675,8 +2648,6 @@ view would leave a wallet one click from deletion.
Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change)
- "Reject" → closes popup (returns rejection to background)
- "Reject" after "Confirm", while the transaction is being sent → the
background's message on the error line, no screen change
- Popup window closed without answering → the request stays pending, and the
site gets no answer. The browser can close this window when the user
clicks away from it, for example to copy the password from a password
@@ -2771,8 +2742,6 @@ view would leave a wallet one click from deletion.
- "Sign" (wrong password, or a signing failure) → error line, no screen
change
- "Reject" → closes popup (returns rejection to background)
- "Reject" after "Sign", while the signature is being checked → the
background's message on the error line, no screen change
- Popup window closed without answering → the request stays pending, as on
TxApproval. Until the request is answered, the toolbar button opens this
screen instead of the wallet.
@@ -3108,11 +3077,6 @@ logged.
- The user's password is run through Argon2id (`crypto_pwhash`) to derive a
256-bit encryption key. Argon2id is memory-hard, making GPU/ASIC brute
force attacks expensive.
- Each use of the password runs one Argon2id derivation at libsodium's
interactive setting: `OPSLIMIT_INTERACTIVE` and `MEMLIMIT_INTERACTIVE`, 2
passes over 64 MiB. Every signature asks for the password, so a light
setting keeps signing quick. The password's length, not the derivation's
cost, is what protects the stored secret.
- The derived key encrypts the secret material using XSalsa20-Poly1305
(`crypto_secretbox`), which provides authenticated encryption (the
ciphertext cannot be tampered with without detection).
-5
View File
@@ -208,9 +208,6 @@ password.
3. Click "Load Temporary Add-on".
4. Select `dist/firefox/manifest.json`.
AutistMask does not work in Firefox private windows. If Firefox is set to "Never
remember history", every window is private, so AutistMask does not work at all.
## Getting Started
### Creating a New Wallet
@@ -435,8 +432,6 @@ see everything unfiltered.
Click the gear icon on the home screen to access settings:
- **Wallets**: Your wallets, and "+ Add wallet".
- **Passwords**: Tick "Allow weak passwords" to choose a password shorter than
12 characters when you add a wallet.
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
-1
View File
@@ -4,7 +4,6 @@
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"incognito": "not_allowed",
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": {
"16": "icons/icon16.png",
+8 -11
View File
@@ -161,9 +161,9 @@ let txApprovalSlot = null;
const APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is being prepared, is on
// screen, or waits with its window closed. It does not claim the other one is
// displayed yet, because for the length of one network round trip it is not.
// the transaction is populated, so the other request is either being prepared
// or on screen. It does not claim the other one is displayed yet, because for
// the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" +
@@ -390,11 +390,10 @@ let toolbarPopupApprovalId = null;
// Point the toolbar popup at the oldest pending approval whose window closed,
// or at the wallet when there is none. The user can then answer that approval
// from the toolbar popup. pendingApprovals lists approvals in the order they
// were added, so the first one found is the oldest. An approval held by a
// running attempt is skipped until that attempt ends.
// were added, so the first one found is the oldest.
function updateToolbarPopup() {
const closed = Object.values(pendingApprovals).find(
(approval) => approval.windowClosed && !approval.attemptInFlight,
(approval) => approval.windowClosed,
);
toolbarPopupApprovalId = closed ? closed.id : null;
if (actionNs && typeof actionNs.setPopup === "function") {
@@ -479,7 +478,6 @@ const APPROVAL_WINDOW_FAILED_MESSAGE =
function claimApproval(approval) {
if (approval.attemptInFlight) return false;
approval.attemptInFlight = true;
updateToolbarPopup();
return true;
}
@@ -488,7 +486,6 @@ function claimApproval(approval) {
// its window while the attempt ran, the toolbar popup opens it for that retry.
function releaseApproval(approval) {
approval.attemptInFlight = false;
updateToolbarPopup();
}
// Open approval in a separate popup window, unless it is no longer pending.
@@ -1491,9 +1488,9 @@ updateToolbarPopup();
// A transaction or signature approval stays pending when its window closes.
// The browser can close that window when the user clicks away from it, for
// example to copy the password from a password manager. The approval is marked
// as closed, so the toolbar popup can open it (see updateToolbarPopup()). An
// attempt already running when the window closes goes on and reports its
// outcome to the page.
// as closed, and from then on the toolbar popup opens it (see
// updateToolbarPopup()). An attempt already running when the window closes
// goes on and reports its outcome to the page.
//
// This listener rejects a site-connection or network-switch approval whose
// window closes before its popup connected its port. Once the port is
+7 -5
View File
@@ -18,10 +18,11 @@
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored
// record takes, so no other write lands in between, by
// the background or by any page (README, "Stored state
// and its version"). The read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// the background or by any page except a page in a
// Firefox private window (README, "Stored state and its
// version"). The read is one storage round trip ahead of
// the write rather than a page lifetime ahead of it
// (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
@@ -72,7 +73,8 @@ async function updateStateOnce(mutate) {
// src/shared/browserApi.js) is held from the read to the write: two
// background turns, or a turn and a page's save, never interleave
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
// while it is held (README, "Stored state and its version").
// while it is held, except a page in a Firefox private window, which Firefox
// gives locks of its own (README, "Stored state and its version").
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds,
-17
View File
@@ -980,23 +980,6 @@
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Passwords</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="settings-allow-weak-passwords"
/>
Allow weak passwords
</label>
<p class="text-xs text-muted">
Lets you choose a password shorter than 12 characters
when you add a wallet.
</p>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Tracked Tokens</h3>
<p class="text-xs text-muted mb-2">
+1 -16
View File
@@ -6,7 +6,6 @@ const {
saveState,
onSaveFailure,
takeStoredNetwork,
clearBalanceNotFetched,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
@@ -89,17 +88,6 @@ let refreshTimer = null;
// removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
//
// The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. trackToken() in
// src/popup/views/helpers.js sets that field. The save takes the field off the
// matching tokens of the stored record (clearBalanceNotFetched() in
// src/shared/state.js). The rest of the stored tracked token list stays as it
// is.
//
// Once the save is done, the wallet list is drawn again. So are the balances
// on AddressDetail or AddressToken when that screen is up. No other screen is
// drawn again, DeleteAddress and its balance lines included.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
@@ -107,7 +95,7 @@ async function doRefreshAndRender() {
await followStoredSettings();
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
const [, readTokens] = await Promise.all([
await Promise.all([
refreshPrices(),
refreshBalances(
wallets,
@@ -139,13 +127,10 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
clearBalanceNotFetched(stored, readTokens);
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
+2 -9
View File
@@ -1,11 +1,4 @@
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -79,7 +72,7 @@ function init(ctx) {
return;
}
log.infof("Adding token", info.symbol, contractAddr);
trackToken({
state.trackedTokens.push({
address: contractAddr,
symbol: info.symbol,
decimals: info.decimals,
+1 -2
View File
@@ -132,8 +132,7 @@ function validatePassword() {
showError("add-wallet-password-error", "Please choose a password.");
return null;
}
// "Allow weak passwords" in Settings lifts the 12-character minimum.
if (!state.allowWeakPasswords && pw.length < 12) {
if (pw.length < 12) {
showError(
"add-wallet-password-error",
"Password must be at least 12 characters.",
+10 -19
View File
@@ -66,27 +66,11 @@ function show() {
});
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
renderBalances();
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
// The USD total and the balance list. Drawn again by each balance refresh
// that returns while this screen is up (doRefreshAndRender() in
// src/popup/index.js), which leaves the rest of the screen as it is.
function renderBalances() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
$("address-balances").innerHTML = balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -100,6 +84,13 @@ function renderBalances() {
ctx.showAddressToken();
});
});
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
let loadedTxs = [];
@@ -296,4 +287,4 @@ function init(_ctx) {
exportPrivkey.init();
}
module.exports = { init, show, renderBalances };
module.exports = { init, show };
+32 -48
View File
@@ -49,18 +49,37 @@ function show() {
const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress;
const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
currentSymbol =
tokenId === "ETH"
? nativeCurrency()
: resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens);
if (tokenId === "ETH") {
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(symbol);
}
currentSymbol = symbol;
$("address-token-title").textContent =
wallet.name +
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(currentSymbol);
displaySymbol(symbol);
// Blockie
const blockieEl = $("address-token-jazzicon");
@@ -82,7 +101,13 @@ function show() {
$("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
renderBalance();
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
@@ -144,47 +169,6 @@ function show() {
loadTransactions(addr.address, tokenId);
}
// The USD total and the balance line of the selected token. Drawn again by
// each balance refresh that returns while this screen is up
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
// screen as it is.
function renderBalance() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === id,
);
const tracked = state.trackedTokens.find(
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has read the
// balance of a token just added: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
if (tb) amount = unknownableAmount(tb.balance);
else amount = tracked && tracked.balanceNotFetched ? null : 0;
price = getPrice(currentSymbol);
}
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(
currentSymbol,
amount,
price,
);
}
async function loadTransactions(address, tokenId) {
try {
const rawTxs = await fetchRecentTransactions(
@@ -364,4 +348,4 @@ function init(_ctx) {
});
}
module.exports = { init, show, renderBalance };
module.exports = { init, show };
+6 -17
View File
@@ -1144,19 +1144,13 @@ function init(_ctx) {
// The window closes only once the background has the rejection. A message
// sent as the window closes can be lost, and a closed window does not
// reject the request. After Confirm, the background refuses a rejection
// while the transaction is being sent. The window then stays open and
// shows the background's message.
// reject the request.
$("btn-reject-tx").addEventListener("click", async () => {
const response = await sendMessage({
await sendMessage({
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: false,
}).catch(() => null);
if (response && response.error) {
showError("approve-tx-error", response.error);
return;
}
}).catch(() => {});
window.close();
});
@@ -1279,18 +1273,13 @@ function init(_ctx) {
if (outcome.retryable) setSignButtonBusy(false);
});
// As for the transaction's Reject above. After Sign, the background
// refuses a rejection while the signature is being checked.
// As for the transaction's Reject above.
$("btn-reject-sign").addEventListener("click", async () => {
const response = await sendMessage({
await sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,
}).catch(() => null);
if (response && response.error) {
showError("approve-sign-error", response.error);
return;
}
}).catch(() => {});
window.close();
});
}
+1 -11
View File
@@ -417,15 +417,6 @@ function balanceLine(symbol, amount, price, tokenId) {
);
}
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
// until a balance refresh has read the token's balance at every address
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
// balance row for the token shows `quantity unknown` for it, not 0.0000.
function trackToken(token) {
state.trackedTokens.push({ ...token, balanceNotFetched: true });
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
@@ -458,7 +449,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (seen.has(t.address.toLowerCase())) continue;
html += balanceLine(
t.symbol,
t.balanceNotFetched ? null : 0,
0,
getPrice(t.symbol),
t.address.toLowerCase(),
);
@@ -838,7 +829,6 @@ module.exports = {
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
trackToken,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
-6
View File
@@ -499,12 +499,6 @@ function init(_ctx) {
await saveState();
});
$("settings-allow-weak-passwords").checked = state.allowWeakPasswords;
$("settings-allow-weak-passwords").addEventListener("change", async () => {
state.allowWeakPasswords = $("settings-allow-weak-passwords").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(
+4 -11
View File
@@ -1,11 +1,4 @@
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -50,7 +43,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
trackToken(token);
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -117,7 +110,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
trackToken(token);
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -163,7 +156,7 @@ function init(_ctx) {
return;
}
log.infof("Adding token", info.symbol, addr);
trackToken({
state.trackedTokens.push({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
+2
View File
@@ -143,6 +143,8 @@ async function resetProfile() {
try {
// Under the lock every write of the stored record takes, so the erase
// cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask"));
} catch (e) {
log.errorf("state recovery: reset failed:", e);
+4 -20
View File
@@ -247,11 +247,7 @@ async function fetchTokenBalances(
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances(), and its balance is not read.
//
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
// balance was not read, as lowercased contract addresses. A token is unread
// when the spoof rule refuses it or its balanceOf fails.
// no entry either, as in fetchTokenBalances().
//
// `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
@@ -292,16 +288,12 @@ async function fetchTrackedTokenBalances(
const reported = explorerRows ?? previous;
const rows = [];
const unread = trackedTokens
.filter((token) => !listed.includes(token))
.map((token) => token.address.toLowerCase());
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
unread.push(lower);
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
@@ -322,15 +314,12 @@ async function fetchTrackedTokenBalances(
holders: entry ? entry.holders : null,
});
});
return { rows, unread };
return rows;
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
// for fetchTokenBalances().
//
// Resolves with the lowercased contract addresses of the tracked tokens whose
// balance it read at every address.
async function refreshBalances(
wallets,
rpcUrl,
@@ -343,8 +332,6 @@ async function refreshBalances(
const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
// The tracked tokens whose balance was not read at some address.
const unread = new Set();
const updates = [];
for (const wallet of wallets) {
@@ -419,14 +406,12 @@ async function refreshBalances(
signal,
),
explorerRequest,
]).then(([trackedResult, explorerRows]) => {
for (const lower of trackedResult.unread) unread.add(lower);
]).then(([trackedRows, explorerRows]) => {
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances =
trackedResult.rows.concat(untrackedRows);
addr.tokenBalances = trackedRows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
@@ -440,7 +425,6 @@ async function refreshBalances(
await Promise.all(updates);
log.debugf("refreshBalances done");
return [...trackedSet].filter((lower) => !unread.has(lower));
}
// Drops the balance of each token not in `trackedTokens` from every address.
-6
View File
@@ -56,9 +56,6 @@ const DEFAULT_STATE = {
// else.
useBlockscout: false,
checkRecipientsOnEtherscan: false,
// "Allow weak passwords" in Settings. While it is off, AddWallet refuses a
// password shorter than 12 characters.
allowWeakPasswords: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
@@ -421,9 +418,6 @@ function normalizePersisted(saved) {
// service.
out.useBlockscout = saved.useBlockscout === true;
out.checkRecipientsOnEtherscan = saved.checkRecipientsOnEtherscan === true;
// Anything but true is off: the setting lets AddWallet accept a short
// password.
out.allowWeakPasswords = saved.allowWeakPasswords === true;
// A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
+5 -26
View File
@@ -128,9 +128,8 @@ function currentNetwork() {
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// `baseline` also lacks any `balanceNotFetched` field that a balance refresh has
// deleted since (clearBalanceNotFetched()). saveState() diffs the live state
// against this to find only the fields THIS page changed since.
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -582,7 +581,9 @@ function reportSaveFailure(err) {
// Each save runs start to finish holding the lock every write of the stored
// record takes (withLock() in src/shared/browserApi.js), so no write by
// another page or the background lands between its read and its write and is
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641).
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox
// private window is the exception: Firefox gives it locks of its own (README,
// "Stored state and its version").
//
// The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be
@@ -614,27 +615,6 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Deletes `balanceNotFetched` from each tracked token whose lowercased
// contract address is in `addresses`. The field goes from `stored`, from
// `state` and from `baseline` alike. `stored` is the record a save of this
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
// from `beforeWrite`). So the deletion is not a change this page made to its
// tracked tokens. Unless this page has changed its tracked tokens itself, the
// save then writes the stored list with only the field gone.
function clearBalanceNotFetched(stored, addresses) {
for (const list of [
stored.trackedTokens,
rawState.trackedTokens,
baseline.trackedTokens,
]) {
for (const token of list) {
if (addresses.includes(token.address.toLowerCase())) {
delete token.balanceNotFetched;
}
}
}
}
// 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
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -681,7 +661,6 @@ module.exports = {
saveState,
onSaveFailure,
takeStoredNetwork,
clearBalanceNotFetched,
loadState,
currentAddress,
currentNetwork,
-441
View File
@@ -1,441 +0,0 @@
// After "Add" on AddToken, AddressDetail lists the new token. Its line reads
// `quantity unknown`, not 0.0000, until a balance refresh has read the token's
// balance at every address, even if the popup closes and opens again
// meanwhile. A token added on SettingsAddToken reads the same wherever it is
// listed. A refresh that returns draws the balances on AddressDetail or
// AddressToken again, so the fetched balance shows without the screen being
// reopened (https://git.eeqj.de/sneak/AutistMask/issues/516).
//
// The tracked token's record carries `balanceNotFetched` until a refresh has
// read the token's balance. Removing the token removes that record. A network
// switch keeps each network's records apart. A refresh takes the field off the
// stored record's tokens and keeps another page's changes to the stored list.
//
// Driven through the real popup over a stored profile on Ethereum Mainnet,
// with "Show tracked tokens with zero balance" on, as it is by default.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
// A holding of a million NEW_TOKEN, as refreshBalances() stores it.
const MILLION = {
address: NEW_TOKEN,
name: "New Token",
symbol: "NEW",
decimals: 18,
balance: "1000000.0",
holders: 3,
};
// NEW_TOKEN as "Add" on AddToken tracks it, once a refresh has read its
// balance.
const NEW_TRACKED = {
address: NEW_TOKEN,
symbol: "NEW",
name: "New Token",
decimals: 18,
};
// A token that another popup page tracks.
const OTHER_TOKEN = {
address: "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
symbol: "OTH",
name: "Other Token",
decimals: 18,
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail for the one address.
function bootOnAddress(extra) {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
...extra,
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as its token
// entries, having read the balance of every token tracked when it started.
// Given null, it finishes having read no token's balance, as when every
// balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
refreshBalances.mockImplementationOnce(
async (wallets, rpcUrl, explorerUrl, trackedTokens) => {
const read = trackedTokens.map((t) => t.address.toLowerCase());
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances;
return read;
},
);
return async (tokenBalances) => {
release(tokenBalances);
await page.settle();
};
}
// Runs the ten-second refresh to the end, finishing it as holdNextRefresh()
// does with `tokenBalances`.
async function refreshOnce(page, tokenBalances) {
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish(tokenBalances);
await ticking;
}
// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup
// answering at once.
async function addNewToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-add-token");
page.node("add-token-address").value = NEW_TOKEN;
await page.click("btn-add-token-confirm");
}
// Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract
// lookup answering at once. It ends on SettingsAddToken.
async function addOnSettingsAddToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-settings-add-token");
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
}
// The "[x]" button Settings draws beside the first tracked token. The page
// stub does not turn the markup Settings draws into elements. So the list
// hands Settings a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Settings opens.
function firstRemoveButton(page) {
const button = { press: null };
page.node("settings-tracked-tokens").querySelectorAll = () => [
{
dataset: { idx: "0" },
addEventListener: (name, fn) => {
button.press = fn;
},
},
];
return button;
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// Another popup page saves `trackedTokens` as the tracked token list. This
// page is not told of the write.
function anotherPageTracks(page, trackedTokens) {
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
trackedTokens,
});
}
// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
// another is named, as markup.
function newTokenRow(page, id = "address-balances") {
return page
.node(id)
.innerHTML.split("<div ")
.find((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
const { state } = require("../src/shared/state");
state.selectedWallet = 0;
state.selectedAddress = 0;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressDetail").show();
await page.settle();
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(page, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
}
test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
const finish = holdNextRefresh(page);
await addNewToken(page);
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await finish([MILLION]);
});
test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
// Stands for the transactions on the screen, which only show() replaces.
page.node("tx-list").innerHTML = "transactions on the screen";
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("1000000.0000");
// Only the balances are drawn again, not the transaction list.
expect(page.node("tx-list").innerHTML).toBe("transactions on the screen");
});
test("a refresh already running when the token is added does not count as reading its balance", async () => {
const page = await bootOnAddress();
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addNewToken(page);
await finishRunning([]);
await ticking;
expect(newTokenRow(page)).toContain("quantity unknown");
// The next one reads the token's balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
holdNextRefresh(closed);
await addNewToken(closed);
cleanupPopup();
// The refresh this boot starts reads no token's balance: the boot's stub
// of refreshBalances() reads none.
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await finish(null);
// Drawn again: the ETH balance it fetched is shown.
expect(page.node("address-balances").innerHTML).toContain("2.0000");
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await finish([MILLION]);
expect(page.node("address-token-balance").innerHTML).toContain(
"1000000.0000",
);
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]);
const finish = holdNextRefresh(page);
await page.click("btn-settings");
await addOnSettingsAddToken(page);
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
const onHome = newTokenRow(page, "wallet-list");
expect(onHome).toContain("quantity unknown");
expect(onHome).not.toContain("0.0000");
await openAddress(page);
const onAddress = newTokenRow(page);
expect(onAddress).toContain("quantity unknown");
expect(onAddress).not.toContain("0.0000");
await finish([MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that returns while AddressToken is up draws its balance again", async () => {
const page = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
expect(page.node("address-token-balance").innerHTML).toContain("1.5000");
await refreshOnce(page, null);
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => {
const page = await bootPopup(unversionedValidProfile());
const removeButton = firstRemoveButton(page);
const stored = () => page.storage.read("autistmask");
await page.click("btn-settings");
await addOnSettingsAddToken(page);
// Settings opened again from Home draws its list with the token in it.
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
await page.click("btn-settings");
await removeButton.press();
await page.settle();
expect(stored().trackedTokens).toEqual([]);
// Nothing in the stored record names the token any more.
expect(JSON.stringify(stored()).toLowerCase()).not.toContain(
NEW_TOKEN.toLowerCase(),
);
await addOnSettingsAddToken(page);
// A second add of the same token is refused.
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 };
expect(stored().trackedTokens).toEqual([
{ ...added, name: "New Token", balanceNotFetched: true },
]);
await refreshOnce(page, []);
expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]);
});
test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => {
// Sepolia tracks a token at the same contract address, also just added.
const sepoliaToken = {
address: NEW_TOKEN,
symbol: "SEP",
name: "Sepolia Token",
decimals: 18,
};
const page = await bootOnAddress({
networkTrackedTokens: {
sepolia: [{ ...sepoliaToken, balanceNotFetched: true }],
},
});
const finishMainnet = holdNextRefresh(page);
await addNewToken(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
// The refresh Add started returns after the switch and writes nothing.
await finishMainnet([MILLION]);
// Sepolia's refresh reads the balance of its own token.
await refreshOnce(page, []);
const stored = page.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([sepoliaToken]);
expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true);
await chooseNetwork(page, "mainnet");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("quantity unknown");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh keeps a token that another page added after this page loaded", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
const otherAdded = { ...OTHER_TOKEN, balanceNotFetched: true };
anotherPageTracks(page, [
{ ...NEW_TRACKED, balanceNotFetched: true },
otherAdded,
]);
await finish([MILLION]);
expect(page.storage.read("autistmask").trackedTokens).toEqual([
NEW_TRACKED,
otherAdded,
]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh does not bring back a token that another page removed after this page loaded", async () => {
const page = await bootOnAddress({ trackedTokens: [OTHER_TOKEN] });
const finish = holdNextRefresh(page);
await addNewToken(page);
anotherPageTracks(page, [{ ...NEW_TRACKED, balanceNotFetched: true }]);
await finish([MILLION]);
expect(page.storage.read("autistmask").trackedTokens).toEqual([
NEW_TRACKED,
]);
});
-87
View File
@@ -1,87 +0,0 @@
// "Allow weak passwords" in Settings is off until the user ticks it. While it
// is off, AddWallet refuses a password shorter than 12 characters. While it is
// on, AddWallet accepts any password that is not empty
// (https://git.eeqj.de/sneak/AutistMask/issues/674). These boot the real popup,
// set the checkbox in Settings as the user does, and then add a wallet.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(async () => ({
salt: "salt",
nonce: "nonce",
ciphertext: "ciphertext",
})),
}));
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const CHECKBOX = "settings-allow-weak-passwords";
const ERROR = "add-wallet-password-error";
afterEach(() => {
cleanupPopup();
});
// Boots a popup that holds one wallet and opens Settings, where the checkbox
// starts unticked. Ticks it when `allow` is true. Then opens AddWallet and
// presses the die for a recovery phrase.
async function openAddWallet(allow) {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checkbox = page.node(CHECKBOX);
expect(checkbox.checked).toBe(false);
if (allow) {
checkbox.checked = true;
for (const fn of checkbox.listeners.change) await fn();
expect(page.storage.read("autistmask").allowWeakPasswords).toBe(true);
}
await page.click("btn-main-add-wallet");
await page.click("btn-generate-phrase");
return page;
}
// Types `password` into both password fields and presses Import.
async function importWith(page, password) {
page.node("add-wallet-password").value = password;
page.node("add-wallet-password-confirm").value = password;
await page.click("btn-add-wallet-confirm");
}
function storedWalletCount(page) {
return page.storage.read("autistmask").wallets.length;
}
describe("Allow weak passwords", () => {
test("unticked: a password shorter than 12 characters is refused", async () => {
const page = await openAddWallet(false);
await importWith(page, "short");
expect(page.text(ERROR)).toBe(
"Password must be at least 12 characters.",
);
expect(storedWalletCount(page)).toBe(1);
expect(page.visibleViews()).toEqual(["add-wallet"]);
});
test("ticked: a password shorter than 12 characters is accepted", async () => {
const page = await openAddWallet(true);
await importWith(page, "short");
expect(page.text(ERROR)).toBe("");
expect(storedWalletCount(page)).toBe(2);
expect(page.visibleViews()).toEqual(["main"]);
});
test.each([false, true])(
"an empty password is refused (ticked: %s)",
async (allow) => {
const page = await openAddWallet(allow);
await importWith(page, "");
expect(page.text(ERROR)).toBe("Please choose a password.");
expect(storedWalletCount(page)).toBe(1);
expect(page.visibleViews()).toEqual(["add-wallet"]);
},
);
});
+7 -56
View File
@@ -176,17 +176,17 @@ test("a connection prompt in the toolbar popup leaves the browser window alone",
expect(env.pageErrors).toEqual([]);
});
// Each row holds a transaction or signature approval as the background
// describes it, plus the element ids of the screen that shows it. `refusal` is
// the message the background refuses a Reject with.
const TX_AND_SIGN_APPROVALS = [
// A closed transaction or signature window leaves its request pending
// (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
// window only once the background has the rejection. A message sent as the
// window closes can be lost, and the request would stay pending although the
// user rejected it.
test.each([
{
kind: "transaction",
view: "approve-tx",
reject: "btn-reject-tx",
error: "approve-tx-error",
type: "AUTISTMASK_TX_RESPONSE",
refusal: "This transaction is already being sent.",
approval: {
type: "tx",
approvedFrom: ADDRESS,
@@ -206,9 +206,7 @@ const TX_AND_SIGN_APPROVALS = [
kind: "signature",
view: "approve-sign",
reject: "btn-reject-sign",
error: "approve-sign-error",
type: "AUTISTMASK_SIGN_RESPONSE",
refusal: "This request is already being signed.",
approval: {
type: "sign",
approvedFrom: ADDRESS,
@@ -219,14 +217,7 @@ const TX_AND_SIGN_APPROVALS = [
},
},
},
];
// A closed transaction or signature window leaves its request pending
// (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
// window only once the background has the rejection. A message sent as the
// window closes can be lost, and the request would stay pending although the
// user rejected it.
test.each(TX_AND_SIGN_APPROVALS)(
])(
"Reject on a $kind approval closes the window only once the background has answered",
async ({ view, reject, type, approval }) => {
let describeApproval = null;
@@ -268,43 +259,3 @@ test.each(TX_AND_SIGN_APPROVALS)(
expect(env.pageErrors).toEqual([]);
},
);
// After Confirm or Sign, the background refuses a Reject while the transaction
// is being sent or the signature checked. The request is not rejected then, so
// the window stays open and shows the background's message.
test.each(TX_AND_SIGN_APPROVALS)(
"Reject on a $kind approval that the background refuses shows the background's message and keeps the window open",
async ({ view, reject, error, type, refusal, approval }) => {
let describeApproval = null;
let answerReject = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
describeApproval = reply;
}
if (msg.type === type) answerReject = reply;
},
},
});
describeApproval({
...approval,
origin: "https://dapp.example",
isPhishingDomain: false,
});
await settle();
const close = jest.fn();
globalThis.window.close = close;
const clicked = env.click(reject);
await settle();
answerReject({ error: refusal, retryable: false });
await clicked;
expect(close).not.toHaveBeenCalled();
expect(env.visibleViews()).toEqual([view]);
expect(env.text(error)).toBe(refusal);
expect(env.pageErrors).toEqual([]);
},
);
-84
View File
@@ -1989,90 +1989,6 @@ describe("a closed approval window leaves the request pending", () => {
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
// The user can close the window while the attempt after Confirm runs. That
// attempt answers the request, so meanwhile the toolbar popup opens the
// wallet. Opened from the toolbar, the approval would look unanswered.
test("when the window closes during an attempt that sends, the toolbar popup keeps opening the wallet", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
bg.closeWindow(1);
await settle();
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("when the window closes during an attempt that fails retryably, the toolbar popup opens the wallet until the attempt ends, then the approval", async () => {
const stalled = deferred();
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.setStateReadHook(async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
});
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
bg.closeWindow(1);
await settle();
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
stalled.resolve();
await settle();
expect(pending.result()).toBeNull();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + pending.id(),
);
// A retry from the toolbar popup holds the approval again, so the
// toolbar popup opens the wallet while that retry runs.
bg.setStateReadHook(null);
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("an address switch still rejects a closed approval", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
@@ -41,7 +41,6 @@ async function holdRefresh(page) {
holders: null,
},
];
return [];
});
const ticking = page.tick();
await page.settle();
@@ -720,7 +720,6 @@ describe("the popup", () => {
tokenRow(TRACKED, "TRK"),
tokenRow(UNTRACKED, "TST"),
];
return [];
});
const ticking = page.tick();
await page.settle();
@@ -765,7 +764,6 @@ describe("the popup", () => {
addr.tokenBalances = after
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
return [];
});
anotherPageSaves(page, { useBlockscout: after });
+1 -2
View File
@@ -969,8 +969,7 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the
// screen. The refresh it starts draws the list again, with the row still
// in it.
// screen, and nothing renders the list again while the screen is up.
const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol },
-1
View File
@@ -48,7 +48,6 @@ async function boot(stored) {
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance;
}
return [];
});
return { page, refreshBalances };
}
-1
View File
@@ -88,7 +88,6 @@ describe.each([
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
trackToken: () => {},
}));
require("../src/popup/views/" + view).init({
-9
View File
@@ -165,15 +165,6 @@ describe("declared icons", () => {
});
});
// Firefox gives a page in a private window locks of its own, so that page's
// save could land between the background's read and its write (README,
// "Stored state and its version").
describe("private windows", () => {
test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed");
});
});
describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs.
+2 -9
View File
@@ -629,15 +629,8 @@ const CONTRACT = [
// the falsy answer is also the DEFAULT_STATE default.
falsy: [0],
})),
// Anything but true is off for these three settings:
// - useBlockscout and checkRecipientsOnEtherscan let a request reach
// another service.
// - allowWeakPasswords lets AddWallet accept a short password.
...[
"useBlockscout",
"checkRecipientsOnEtherscan",
"allowWeakPasswords",
].map((field) => ({
// Each lets a request reach another service, so anything but true is off.
...["useBlockscout", "checkRecipientsOnEtherscan"].map((field) => ({
field,
kind: KIND.SCALAR,
hostile: [42, "notabool", { a: 1 }],
+3 -2
View File
@@ -6,8 +6,9 @@
// now holds a lock from its read to its write.
//
// The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and its pages share
// the browser's (tests/support/navigatorLocks.js).
// storage stub, and share the lock the way the background and a page outside
// a Firefox private window share the browser's
// (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub");
+3 -2
View File
@@ -4,8 +4,9 @@
// the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's
// (README, "Stored state and its version").
// shares it, as the extension's background and its pages share the browser's,
// except a page in a Firefox private window (README, "Stored state and its
// version").
//
// Run before each test file by `setupFiles` in package.json.
+2 -3
View File
@@ -279,11 +279,10 @@ async function bootPopup(stored, options) {
getAddressValue: () => ({ usd: null, partial: false }),
}));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it. refreshBalances()
// answers as a refresh that read no tracked token's balance.
// no used address: AddWallet reads `addresses` from it.
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null),
-28
View File
@@ -307,31 +307,3 @@ test("a tracked token wearing a known symbol from another contract is not listed
);
expect(first.tokenBalances).toEqual([]);
});
test("the refresh resolves with each tracked token whose balance it read at every address", async () => {
const flaky = "0x9999999999999999999999999999999999999999";
holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n };
// balanceOf answers at FIRST and fails at SECOND.
holdings[flaky] = { [FIRST]: 2000000n };
const read = await refreshBalances(
[
{
addresses: [
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
},
],
RPC_URL,
EXPLORER_URL,
[
TRACKED,
{ address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 },
// A known symbol from another contract: its balance is not read.
{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 },
],
"mainnet",
new AbortController().signal,
);
expect(read).toEqual([TOKEN]);
});
+2 -4
View File
@@ -159,9 +159,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
"sepolia",
expect.anything(),
);
// No refresh has read the new token's balance yet.
const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true };
expect(stored().trackedTokens).toEqual([added]);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
@@ -173,7 +171,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([added]);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI");