1 Commits
Author SHA1 Message Date
sneak 6cc06b821a fix: the address screen shows a token just added at its fetched balance, not 0.0000 (closes #516)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A balance refresh that returns now draws the balances on AddressDetail or
AddressToken again when that screen is up, not only the wallet list. Their
transaction lists are left alone.

Every add path on AddToken and SettingsAddToken tracks a token through
trackToken(), which gives the tracked record `balanceNotFetched: true`. While
the field is set, the token's line reads `quantity unknown` instead of 0.0000.
The popup's refresh deletes the field once it has read the token's balance at
every address. refreshBalances() now resolves with those tokens. The field is
part of the tracked record, so removing the token removes it, and each network
keeps its own records.

Model: opus-5-5
2026-10-26 23:18:45 +00:00
11 changed files with 177 additions and 841 deletions

No files matched your search

+20 -41
View File
@@ -512,10 +512,7 @@ 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 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 it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). Closing a transaction [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
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.
The limits of that coverage and of the rest of the Chrome suite, none of them The limits of that coverage and of the rest of the Chrome suite, none of them
papered over: papered over:
@@ -524,9 +521,9 @@ papered over:
network with real funds; that remains a human pass before 1.0.0. network with real funds; that remains a human pass before 1.0.0.
- The site-connection prompt is raised through `chrome.action.openPopup()`, and - The site-connection prompt is raised through `chrome.action.openPopup()`, and
headless Chromium's browser-action popup is not a page Playwright can see or headless Chromium's browser-action popup is not a page Playwright can see or
click. So that prompt, and an approval reopened after its window closed, are click, so that one prompt is driven at the URL the extension itself puts on
opened in a tab at the URL the extension puts on the action. Whether a real the action — the same page and the same approval id, but whether a real
toolbar click shows that page is not observable here. toolbar click shows it is not observable here.
- Each site-connection request is made 1.5 seconds after the tab it will be - Each site-connection request is made 1.5 seconds after the tab it will be
driven in is opened (`APPROVAL_TAB_SETTLE_MS` in `tests/e2e/run.js`), because driven in is opened (`APPROVAL_TAB_SETTLE_MS` in `tests/e2e/run.js`), because
opening that tab closes the previous prompt's toolbar popup; a request made opening that tab closes the previous prompt's toolbar popup; a request made
@@ -645,24 +642,15 @@ above, it does not fail on page errors.
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers: It covers popup load, the StateRecovery screen (the same cases as the Chrome
suite), wallet creation through the UI, the Add Token screen, and the dApp round
- popup load trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
- the StateRecovery screen, with the same cases as the Chrome suite rejected and then approved, `eth_sendTransaction`, and a closed approval window
- wallet creation through the UI rejecting with EIP-1193 4001 — driven through the real content script,
- the Add Token screen background page and approval windows. The popup open all along is required to
- `eth_requestAccounts` from a dApp show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop
- `personal_sign` from a dApp it once the switch back is. The `personal_sign` prompt is also required to fit
- `wallet_switchEthereumChain` from a dApp, rejected and then approved its window without scrolling sideways, with the extension's pages at 170% zoom.
- `eth_sendTransaction` from a dApp
- an approval window closed without an answer, which leaves its request pending
until it is rejected
Each dApp request runs through the real content script and background page, and
opens a real approval window. The popup open all along is required to show the
`[TESTNET]` banner once the switch to Sepolia is approved and to drop it once
the switch back is. The `personal_sign` prompt is also required to fit its
window without scrolling sideways, with the extension's pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and **no npm dependencies at all**: it is built on global `fetch` and
@@ -1494,8 +1482,8 @@ send-screen token selector, the address total and the remove-address warning
confirmation screens show it when its token is the one being sent. Tracked 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 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 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 `quantity unknown` until a refresh has read its balance at every address (see
its balance at every address (see AddToken). AddToken).
#### Stored state and its version #### Stored state and its version
@@ -2552,7 +2540,7 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an - 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 "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 the token's balance, its line reads `quantity unknown`, as after "Add" on
AddToken AddToken
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
@@ -2675,14 +2663,8 @@ view would leave a wallet one click from deletion.
Data Model) → full-sentence error on the error line saying why, nothing Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change) signed (no screen change)
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- "Reject" after "Confirm", while the transaction is being sent → the - Popup window closed without answering → the request is rejected with
background's message on the error line, no screen change EIP-1193 code 4001
- 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
manager. Until the request is answered, the toolbar button opens this
screen instead of the wallet. When several requests wait with their
windows closed, the toolbar button opens the oldest first.
#### SignApproval (`approve-sign`) #### SignApproval (`approve-sign`)
@@ -2771,11 +2753,8 @@ view would leave a wallet one click from deletion.
- "Sign" (wrong password, or a signing failure) → error line, no screen - "Sign" (wrong password, or a signing failure) → error line, no screen
change change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- "Reject" after "Sign", while the signature is being checked → the - Popup window closed without answering → the request is rejected with
background's message on the error line, no screen change EIP-1193 code 4001
- 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.
#### NetworkApproval (`approve-network`) #### NetworkApproval (`approve-network`)
-7
View File
@@ -353,13 +353,6 @@ enter your password and click "Confirm" to authorize it. Message and typed-data
signature requests work the same way, with a "Sign" button, and also require signature requests work the same way, with a "Sign" button, and also require
your password. your password.
If a transaction or signature popup closes before you answer, for example when
you click away to copy your password, the request stays open. Closing the popup
does not turn the request down. Click the AutistMask icon in your browser
toolbar to bring the request back. Until you answer, the icon opens the request
instead of your wallet. If several requests are waiting this way, the oldest
comes first.
A connected site can also ask to switch the wallet's network. A popup shows the A connected site can also ask to switch the wallet's network. A popup shows the
site's origin, the current network and the requested network. Nothing changes site's origin, the current network and the requested network. Nothing changes
unless you click "Switch"; clicking "Reject" or closing the popup leaves unless you click "Switch"; clicking "Reject" or closing the popup leaves
+67 -73
View File
@@ -161,9 +161,9 @@ let txApprovalSlot = null;
const APPROVAL_PENDING_CODE = -32002; const APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before // 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 // the transaction is populated, so the other request is either being prepared
// screen, or waits with its window closed. It does not claim the other one is // or on screen. It does not claim the other one is displayed yet, because for
// displayed yet, because for the length of one network round trip it is not. // the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE = const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" + "AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" + " already in progress, so this one was not sent. Please finish that" +
@@ -383,26 +383,14 @@ async function proxyRpc(method, params) {
return json.result; return json.result;
} }
// The approval the toolbar popup is set to open, or null while it opens the // The site-connection approval the toolbar popup is set to open, or null while
// wallet. Set only by updateToolbarPopup() and showInToolbarPopup(). // it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
let toolbarPopupApprovalId = null; let toolbarPopupApprovalId = null;
// Point the toolbar popup at the oldest pending approval whose window closed, function resetPopupUrl() {
// or at the wallet when there is none. The user can then answer that approval toolbarPopupApprovalId = null;
// 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.
function updateToolbarPopup() {
const closed = Object.values(pendingApprovals).find(
(approval) => approval.windowClosed && !approval.attemptInFlight,
);
toolbarPopupApprovalId = closed ? closed.id : null;
if (actionNs && typeof actionNs.setPopup === "function") { if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ actionNs.setPopup({ popup: "src/popup/index.html" });
popup: closed
? "src/popup/index.html?approval=" + closed.id
: "src/popup/index.html",
});
} }
} }
@@ -432,16 +420,15 @@ function settleApproval(id, result, options) {
// approval stops being alive. // approval stops being alive.
releaseTxApprovalSlotFor(id); releaseTxApprovalSlotFor(id);
approval.resolve(result); approval.resolve(result);
updateToolbarPopup(); resetPopupUrl();
return true; return true;
} }
// What a pending approval resolves to when it is given up on rather than // What a pending approval resolves to when it is given up on rather than
// answered: a site-connection or network-switch window was closed, or the // answered: the window was closed, or could not be opened at all. A tx or sign
// window could not be opened at all. An address switch in the wallet gives up // approval answers the requesting page in EIP-1193 shape; a site-connection or
// pending approvals too. A tx or sign approval answers the requesting page in // network-switch approval answers its handler in the shape the popup's
// EIP-1193 shape; a site-connection or network-switch approval answers its // decision has, as a refusal.
// handler in the shape the popup's decision has, as a refusal.
function abandonedResult(approval, code, message) { function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } }; return { error: { code, message } };
@@ -449,9 +436,8 @@ function abandonedResult(approval, code, message) {
return { approved: false, remember: false }; return { approved: false, remember: false };
} }
// A site-connection or network-switch window the user closed without answering // A window the user closed without answering is a refusal by the user, which
// is a refusal by the user, which is 4001 and the wording every other rejection // is 4001 and the wording every other rejection path already uses.
// path already uses.
const APPROVAL_REJECTED_CODE = 4001; const APPROVAL_REJECTED_CODE = 4001;
const APPROVAL_REJECTED_MESSAGE = "User rejected the request."; const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
@@ -479,16 +465,31 @@ const APPROVAL_WINDOW_FAILED_MESSAGE =
function claimApproval(approval) { function claimApproval(approval) {
if (approval.attemptInFlight) return false; if (approval.attemptInFlight) return false;
approval.attemptInFlight = true; approval.attemptInFlight = true;
updateToolbarPopup();
return true; return true;
} }
// Release an approval whose attempt failed in a way the user can retry. // Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it. If the user closed // Nothing was broadcast, so the next attempt may claim it.
// its window while the attempt ran, the toolbar popup opens it for that retry. //
// Unless the window it would be retried in is already gone. The user closed it
// while the attempt was running and settleApproval() declined then, correctly,
// because the attempt still owned the approval; the attempt has now failed, so
// nothing owns it and nothing can reach it. Left standing it would hold the
// requesting page's promise open forever and, with it, the transaction
// approval slot. It is settled here as the rejection the closed window
// already meant.
function releaseApproval(approval) { function releaseApproval(approval) {
approval.attemptInFlight = false; approval.attemptInFlight = false;
updateToolbarPopup(); if (approval.windowClosed) {
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
} }
// Open approval in a separate popup window, unless it is no longer pending. // Open approval in a separate popup window, unless it is no longer pending.
@@ -711,7 +712,9 @@ function isExtensionSender(sender) {
// had refused. // had refused.
// //
// TX and sign approvals do not decide here. They stay pending across a // TX and sign approvals do not decide here. They stay pending across a
// disconnect, and across their window closing, until the user answers them. // disconnect. When their window closes, the windows.onRemoved listener below
// rejects them, unless an approved attempt is still running, in which case
// that attempt answers the page.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1]; const id = port.name.split(":")[1];
@@ -739,7 +742,9 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (approval) { if (approval) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
// It stays pending until the user answers it. // When its window closes, windows.onRemoved rejects it,
// unless an approved attempt is still running, in which
// case that attempt answers the page.
return; return;
} }
settleApproval(id, { approved: false, remember: false }); settleApproval(id, { approved: false, remember: false });
@@ -1292,7 +1297,7 @@ async function broadcastAccountsChanged() {
windowsRemove(approval.windowId).catch(() => {}); windowsRemove(approval.windowId).catch(() => {});
} }
} }
updateToolbarPopup(); resetPopupUrl();
// An address switch ends every connection allowed without "Remember". This // An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which // waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives. // are still rejected the moment the switch arrives.
@@ -1484,44 +1489,36 @@ if (runtime.onStartup) {
} }
startBackgroundJobs(); startBackgroundJobs();
// The toolbar popup keeps its URL when the worker stops. A worker that starts // When approval window is closed without a response, treat as rejection.
// holds no approvals, so the toolbar popup is pointed back at the wallet. // "Without a response" is the operative part: the popup stays open across the
updateToolbarPopup(); // verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it.
// A transaction or signature approval stays pending when its window closes. // settleApproval() refuses those, which leaves the attempt to report its real
// The browser can close that window when the user clicks away from it, for // outcome to the page — and the window is recorded as gone, so that an attempt
// example to copy the password from a password manager. The approval is marked // which then fails retryably settles instead of waiting in a window that no
// as closed, so the toolbar popup can open it (see updateToolbarPopup()). An // longer exists.
// 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 // A site-connection or network-switch approval whose popup connected its port
// window closes before its popup connected its port. Once the port is // is not decided here. That popup approves and closes in the same breath, and
// connected, the approval is left to the port. That popup approves and closes // this event races the decision on a channel of its own — the same race the
// in the same breath, and this event races the decision on a channel of its // port exists to end. Its port disconnect says the same thing this event does,
// own — the same race the port exists to end. Its port disconnect says the // in an order that is defined, so the disconnect is left to say it. The window
// same thing this event does, in an order that is defined, so the disconnect is // closing before any port connected is the one case with nothing else to speak
// left to say it. The window closing before any port connected is the one case // for it, and is rejected here so the dApp is not left waiting on a window that
// with nothing else to speak for it, and is rejected here so the dApp is not // is gone.
// left waiting on a window that is gone.
if (windowsNs && windowsNs.onRemoved) { if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => { windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
if (approval.type === "tx" || approval.type === "sign") { const decidedOnPort =
approval.windowClosed = true; approval.type !== "tx" && approval.type !== "sign";
updateToolbarPopup(); if (decidedOnPort && approval.portConnected) continue;
continue; const rejection = abandonedResult(
} approval,
if (approval.portConnected) continue; APPROVAL_REJECTED_CODE,
settleApproval( APPROVAL_REJECTED_MESSAGE,
id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
); );
if (!settleApproval(id, rejection)) approval.windowClosed = true;
} }
}); });
} }
@@ -1666,9 +1663,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
return false; return false;
} }
// The popup waits for this answer before it closes its window. return true;
sendResponse(null);
return false;
} }
// The popup signs; it reports back here when it could not. Keep the // The popup signs; it reports back here when it could not. Keep the
@@ -1868,8 +1863,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
return false; return false;
} }
sendResponse(null); return true;
return false;
} }
// The popup signs; it reports back here when it could not. Keep the // The popup signs; it reports back here when it could not. Keep the
+9 -9
View File
@@ -6,7 +6,6 @@ const {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
clearBalanceNotFetched,
loadState, loadState,
} = require("../shared/state"); } = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
@@ -92,10 +91,7 @@ let refreshTimer = null;
// //
// The save also deletes `balanceNotFetched` from each tracked token whose // The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. trackToken() in // balance this refresh read at every address. trackToken() in
// src/popup/views/helpers.js sets that field. The save takes the field off the // src/popup/views/helpers.js sets that field.
// 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 // 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 // on AddressDetail or AddressToken when that screen is up. No other screen is
@@ -139,7 +135,11 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) { if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens); dropUntrackedTokens(state.wallets, stored.trackedTokens);
} }
clearBalanceNotFetched(stored, readTokens); for (const token of state.trackedTokens) {
if (readTokens.includes(token.address.toLowerCase())) {
delete token.balanceNotFetched;
}
}
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
return true; return true;
}); });
@@ -296,9 +296,9 @@ async function followStoredSettings() {
// zoom it reports, 170% among them, so once the page has its new width (its // zoom it reports, 170% among them, so once the page has its new width (its
// resize event) it is measured again, and the window widened again while the // resize event) it is measured again, and the window widened again while the
// page still falls short, three times at most. A window that cannot grow sends // page still falls short, three times at most. A window that cannot grow sends
// no resize event, and the page stops there. An approval can also show in the // no resize event, and the page stops there. The connection prompt can also
// toolbar popup, whose window is the user's browser window; that one is left // show in the toolbar popup, whose window is the user's browser window; that
// alone. // one is left alone.
async function widenApprovalWindow() { async function widenApprovalWindow() {
const page = document.documentElement; const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return; if (page.scrollWidth <= page.clientWidth) return;
+7 -22
View File
@@ -55,7 +55,7 @@ const {
} = require("../../shared/approvalVerify"); } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const { runtimeApi, sendMessage } = require("../../shared/browserApi"); const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
@@ -1142,21 +1142,12 @@ function init(_ctx) {
} }
}); });
// The window closes only once the background has the rejection. A message $("btn-reject-tx").addEventListener("click", () => {
// sent as the window closes can be lost, and a closed window does not notify({
// 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.
$("btn-reject-tx").addEventListener("click", async () => {
const response = await sendMessage({
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
}).catch(() => null); });
if (response && response.error) {
showError("approve-tx-error", response.error);
return;
}
window.close(); window.close();
}); });
@@ -1279,18 +1270,12 @@ function init(_ctx) {
if (outcome.retryable) setSignButtonBusy(false); if (outcome.retryable) setSignButtonBusy(false);
}); });
// As for the transaction's Reject above. After Sign, the background $("btn-reject-sign").addEventListener("click", () => {
// refuses a rejection while the signature is being checked. notify({
$("btn-reject-sign").addEventListener("click", async () => {
const response = await sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
}).catch(() => null); });
if (response && response.error) {
showError("approve-sign-error", response.error);
return;
}
window.close(); window.close();
}); });
} }
+2 -25
View File
@@ -128,9 +128,8 @@ function currentNetwork() {
// The persisted fields as this page held them when its last loadState() // The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network // finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// `baseline` also lacks any `balanceNotFetched` field that a balance refresh has // saveState() diffs the live state against this to find only the fields THIS
// deleted since (clearBalanceNotFetched()). saveState() diffs the live state // page changed since.
// against this 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
@@ -614,27 +613,6 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(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 // 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
@@ -681,7 +659,6 @@ module.exports = {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
clearBalanceNotFetched,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+1 -60
View File
@@ -8,8 +8,7 @@
// //
// The tracked token's record carries `balanceNotFetched` until a refresh has // The tracked token's record carries `balanceNotFetched` until a refresh has
// read the token's balance. Removing the token removes that record. A network // 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 // switch keeps each network's records apart.
// 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, // 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. // with "Show tracked tokens with zero balance" on, as it is by default.
@@ -33,23 +32,6 @@ const MILLION = {
holders: 3, 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(() => { afterEach(() => {
cleanupPopup(); cleanupPopup();
}); });
@@ -155,15 +137,6 @@ async function chooseNetwork(page, networkId) {
await page.settle(); 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 // NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
// another is named, as markup. // another is named, as markup.
function newTokenRow(page, id = "address-balances") { function newTokenRow(page, id = "address-balances") {
@@ -407,35 +380,3 @@ test("a refresh on another network leaves the new token reading quantity unknown
expect(newTokenRow(page)).toContain("1000000.0000"); 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,
]);
});
-134
View File
@@ -12,7 +12,6 @@ const {
cleanupPopup, cleanupPopup,
settle, settle,
unversionedValidProfile, unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
afterEach(cleanupPopup); afterEach(cleanupPopup);
@@ -175,136 +174,3 @@ test("a connection prompt in the toolbar popup leaves the browser window alone",
expect(windows.updates).toEqual([]); expect(windows.updates).toEqual([]);
expect(env.pageErrors).toEqual([]); 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 = [
{
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,
approvedTx: {
chainId: 1,
to: ADDRESS,
value: "0",
data: "0x",
gasLimit: "21000",
maxFeePerGas: "1000000000",
maxPriorityFeePerGas: "1000000000",
nonce: 0,
},
},
},
{
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,
signParams: {
method: "personal_sign",
message: "0x48656c6c6f",
from: ADDRESS,
},
},
},
];
// 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;
let answerReject = null;
const sent = [];
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) {
sent.push(msg);
answerReject = reply;
}
},
},
});
describeApproval({
...approval,
origin: "https://dapp.example",
isPhishingDomain: false,
});
await settle();
expect(env.visibleViews()).toEqual([view]);
const close = jest.fn();
globalThis.window.close = close;
const clicked = env.click(reject);
await settle();
expect(sent).toEqual([{ type, id: "approval-1", approved: false }]);
expect(close).not.toHaveBeenCalled();
answerReject(null);
await clicked;
expect(close).toHaveBeenCalledTimes(1);
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([]);
},
);
+47 -294
View File
@@ -241,11 +241,10 @@ function loadBackground(options) {
let connectListener = null; let connectListener = null;
const created = []; const created = [];
const removed = []; const removed = [];
// Every URL the background put on the browser action. When a test gives // Every URL the background put on the browser action. A site approval
// the URL the action holds before the worker starts, that URL comes // raised through action.openPopup() opens no window at all, so this is
// first. A site approval raised through action.openPopup() opens no // the only place its id appears.
// window at all, so this is the only place its id appears. const actionPopups = [];
const actionPopups = opts.toolbarPopup ? [opts.toolbarPopup] : [];
const openPopup = jest.fn(() => Promise.resolve()); const openPopup = jest.fn(() => Promise.resolve());
global.chrome = { global.chrome = {
@@ -775,9 +774,10 @@ describe("one transaction approval at a time", () => {
// The user closes an approval window that looks hung while the attempt // The user closes an approval window that looks hung while the attempt
// behind it is still running, and that attempt then fails in a way that // behind it is still running, and that attempt then fails in a way that
// leaves the approval standing for a retry. The toolbar popup opens it, so // would normally leave the approval standing for a retry. There is no
// the user can still retry it, and answering it frees the slot. // window left to retry in, so leaving it standing answers the requesting
test("an approval whose window closed under a failed attempt is retried from the toolbar popup, and frees the next request", async () => { // page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred(); const stalled = deferred();
const bg = loadBackground(); const bg = loadBackground();
@@ -808,28 +808,11 @@ describe("one transaction approval at a time", () => {
stalled.resolve(); stalled.resolve();
await settle(); await settle();
expect(first.result()).toBeNull(); expect(first.result()).toEqual({
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe( error: { code: 4001, message: "User rejected the request." },
"src/popup/index.html?approval=" + first.id(), });
);
bg.setStateReadHook(null); bg.setStateReadHook(null);
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
const second = bg.requestTx(); const second = bg.requestTx();
await settle(); await settle();
expect(second.result()).toBeNull(); expect(second.result()).toBeNull();
@@ -870,10 +853,8 @@ describe("one transaction approval at a time", () => {
const first = bg.requestTx(); const first = bg.requestTx();
await settle(); await settle();
bg.send( // The user closes the approval window, which rejects it.
{ type: "AUTISTMASK_TX_RESPONSE", id: first.id(), approved: false }, bg.closeWindow(1);
{ url: bg.fromPopup.url },
);
await settle(); await settle();
expect(first.result()).toEqual({ expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." }, error: { code: 4001, message: "User rejected the request." },
@@ -916,7 +897,7 @@ describe("the worker keeps running while an approval is pending", () => {
try { try {
const bg = loadBackground(); const bg = loadBackground();
const calls = global.chrome.runtime.getPlatformInfo; const calls = global.chrome.runtime.getPlatformInfo;
const pending = bg.requestTx(); bg.requestTx();
await settle(); await settle();
expect(startTimer).toHaveBeenCalledTimes(1); expect(startTimer).toHaveBeenCalledTimes(1);
expect(startTimer.mock.calls[0][1]).toBe(20000); expect(startTimer.mock.calls[0][1]).toBe(20000);
@@ -925,23 +906,11 @@ describe("the worker keeps running while an approval is pending", () => {
tick(); tick();
expect(calls).toHaveBeenCalledTimes(1); expect(calls).toHaveBeenCalledTimes(1);
// A closed window leaves the approval pending, so the calls go on. // The user closes the approval window, which rejects it.
bg.closeWindow(1); bg.closeWindow(1);
await settle(); await settle();
tick(); tick();
expect(calls).toHaveBeenCalledTimes(2); expect(calls).toHaveBeenCalledTimes(1);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: false,
},
{ url: bg.fromPopup.url },
);
await settle();
tick();
expect(calls).toHaveBeenCalledTimes(2);
expect(stopTimer).toHaveBeenCalledWith(1); expect(stopTimer).toHaveBeenCalledWith(1);
} finally { } finally {
startTimer.mockRestore(); startTimer.mockRestore();
@@ -1872,237 +1841,6 @@ describe("the interlock releases a failed attempt", () => {
}); });
}); });
// 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 toolbar popup then
// opens the approval, and the user answers it there
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
describe("a closed approval window leaves the request pending", () => {
// The URL the toolbar popup opens now.
function toolbarPopup(bg) {
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 () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + pending.id(),
);
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("Reject in the toolbar popup answers 4001, and the toolbar popup opens the wallet again", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + pending.id(),
);
const reject = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: pending.id(),
approved: false,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
// The popup closes its window only once this answer arrives.
expect(reject.sendResponse).toHaveBeenCalledWith(null);
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("with two closed, the toolbar popup opens the older one first, then the other", async () => {
const bg = loadBackground();
const sign = bg.requestSign();
await settle();
const signId = sign.id();
const tx = bg.requestTx();
await settle();
// The signature request is in window 1 and the transaction in window
// 2. The newer window is closed first.
bg.closeWindow(2);
bg.closeWindow(1);
await settle();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + signId,
);
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id: signId, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + tx.id(),
);
const reject = bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id: tx.id(), approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(tx.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(reject.sendResponse).toHaveBeenCalledWith(null);
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();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
bg.send(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
// The worker can stop while an approval waits with its window closed. The
// approval is lost with the worker, but the toolbar popup keeps its URL.
// The next worker points the toolbar popup back at the wallet.
test("a worker that starts points the toolbar popup back at the wallet", () => {
const bg = loadBackground({
toolbarPopup: "src/popup/index.html?approval=lost-approval",
});
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
});
// The claim is what makes one approval one broadcast, so it has to hold // The claim is what makes one approval one broadcast, so it has to hold
// against everything else that retires an approval, not just against a second // against everything else that retires an approval, not just against a second
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting // AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
@@ -2223,6 +1961,20 @@ describe("a claimed approval outlives every other retirement path", () => {
}); });
// The refusals above must not cost the rejection its ordinary meaning. // The refusals above must not cost the rejection its ordinary meaning.
test("with no attempt running, closing the window still rejects", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
});
test("with no attempt running, an active-address switch still rejects and closes", async () => { test("with no attempt running, an active-address switch still rejects and closes", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
@@ -2832,12 +2584,15 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// That skip must not reach a tx or sign approval. The popup connects its // The `isSite &&` half of that skip, which is what keeps it from reaching
// port in show() before it knows the approval's type, and the background // a tx or sign approval. The popup connects its port in show() before it
// sets portConnected without looking at the type either, so a tx approval // knows the approval's type, and the background sets portConnected without
// carries the flag too. If the window event skipped it, windowClosed would // looking at the type either, so a tx approval in the fallback window
// never be set, and the toolbar popup would never open the approval. // carries the flag too. Without the conjunct the window event would skip
test("a tx window closed with the port connected is still opened by the toolbar popup", async () => { // it, windowClosed would never be set, releaseApproval() would never settle
// it, and the page would hang — the #271 regression this guard is written
// around.
test("a tx window closed with the port connected still rejects", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
await settle(); await settle();
@@ -2845,10 +2600,9 @@ describe("a site connection decided as the popup closes", () => {
await settle(); await settle();
bg.closeWindow(1); bg.closeWindow(1);
await settle(); await settle();
expect(pending.result()).toBeNull(); expect(pending.result()).toEqual({
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe( error: { code: 4001, message: "User rejected the request." },
"src/popup/index.html?approval=" + pending.id(), });
);
}); });
}); });
@@ -3240,14 +2994,13 @@ describe("where an approval window opens", () => {
expect(bg.created[1].left).toBeUndefined(); expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined(); expect(bg.created[1].top).toBeUndefined();
// The request waits on the second window rather than failing. Once // The request waits on the second window rather than failing:
// that window closes, the toolbar popup opens the request. // closing that window is refusing the prompt.
expect(sign.result()).toBeNull(); expect(sign.result()).toBeNull();
bg.closeWindow(2); bg.closeWindow(2);
await settle(); await settle();
expect(sign.result()).toBeNull(); expect(sign.result()).toEqual({
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe( error: { code: 4001, message: "User rejected the request." },
"src/popup/index.html?approval=" + sign.id(), });
);
}); });
}); });
+8 -41
View File
@@ -1035,14 +1035,11 @@ step(
}, },
); );
// A closed approval window leaves its request pending, and the toolbar button
// opens it (https://git.eeqj.de/sneak/AutistMask/issues/673). WebDriver cannot
// see the toolbar popup, so its page is opened in a tab, at the URL the
// extension put on the button.
step( step(
"a closed approval window leaves the request pending until it is rejected", "closing an approval window rejects the request with 4001",
async (env) => { async (env) => {
const d = env.driver; const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign-closed", "personal_sign", [ await startRequest(d, "sign-closed", "personal_sign", [
@@ -1056,45 +1053,15 @@ step(
// Closed, not rejected: this is the windows.onRemoved path, which can // Closed, not rejected: this is the windows.onRemoved path, which can
// only fire if windows.create() handed back a window id for the // only fire if windows.create() handed back a window id for the
// approval to be matched against. Without that id the toolbar button // approval to be matched against — call site 4 in the issue, and the
// never opens the approval, and this step fails. // reason suppressing that write-back turns this step red.
await d.closeWindow(env.dappWindow); await d.closeWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign-closed", 2000);
await assertUserRejection(d, "sign-closed", "a closed approval window");
assert( assert(
outcome.settled === "pending", env.server.broadcast.length === before,
"closing the approval window answered the page: " + "a closed approval window still put a transaction on the node",
JSON.stringify(outcome),
); );
await d.switchToWindow(env.popupWindow);
const url = await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.browserAction.getPopup({}).then(done, (e) => done(String(e)));`,
);
assert(
url.includes("?approval="),
"the toolbar button does not open the pending approval: " + url,
);
const reopened = await d.newWindow("tab");
await d.switchToWindow(reopened);
await d.navigate(url);
await d.waitVisible("#view-approve-sign");
await d.click("#btn-reject-sign");
await d.switchToWindow(env.dappWindow);
await assertUserRejection(
d,
"sign-closed",
"the signature request rejected from the toolbar button",
);
// A page may close only a window a script opened, so the tab can
// still be open.
const left = await d.findWindow((u) => u.includes("?approval="));
if (left) {
await d.switchToWindow(left);
await d.closeWindow(env.dappWindow);
}
}, },
); );
+16 -135
View File
@@ -4111,12 +4111,11 @@ async function waitForApprovalWindow(ctx, timeout = 30000) {
// reservation exists. // reservation exists.
// How long the tab creation above is given to take effect before the request // How long the tab creation above is given to take effect before the request
// that raises the next prompt is issued. Dismissing a browser-action popup // that raises the next prompt is issued. Dismissing a browser-action popup
// disconnects its port, and src/background/index.js calls // disconnects its port, and src/background/index.js calls resetPopupUrl() on
// updateToolbarPopup() on that disconnect; issued too early, the next // that disconnect; issued too early, the next request's setPopup() is undone
// request's setPopup() is undone by the previous popup's teardown, // by the previous popup's teardown, chrome.action.openPopup() then rejects,
// chrome.action.openPopup() then rejects, and the fallback window it opens // and the fallback window it opens dismisses the popup that was just raised —
// dismisses the popup that was just raised — which settles the fresh approval // which settles the fresh approval as a rejection before it can be seen.
// as a rejection before it can be seen.
// Every one of those steps was observed. 1.5s is comfortably past it. // Every one of those steps was observed. 1.5s is comfortably past it.
const APPROVAL_TAB_SETTLE_MS = 1500; const APPROVAL_TAB_SETTLE_MS = 1500;
@@ -4142,17 +4141,6 @@ async function reserveApprovalTab(env) {
return env.approvalTab; return env.approvalTab;
} }
// The URL the extension has put on the action: the page the toolbar button
// opens.
function toolbarPopupUrl(env) {
return env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.action.getPopup({}, (u) => resolve(String(u)));
}),
);
}
// The site-connection prompt: the window the extension opened if it managed // The site-connection prompt: the window the extension opened if it managed
// to open one, and the reserved tab at the action's approval URL otherwise. // to open one, and the reserved tab at the action's approval URL otherwise.
async function openSiteApprovalPopup(env, timeout = 30000) { async function openSiteApprovalPopup(env, timeout = 30000) {
@@ -4163,7 +4151,12 @@ async function openSiteApprovalPopup(env, timeout = 30000) {
.find((p) => !p.isClosed() && p.url().includes("?approval=")); .find((p) => !p.isClosed() && p.url().includes("?approval="));
if (existing) return existing; if (existing) return existing;
const url = await toolbarPopupUrl(env); const url = await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.action.getPopup({}, (u) => resolve(String(u)));
}),
);
if (url.includes("?approval=")) { if (url.includes("?approval=")) {
await env.approvalTab.goto(url); await env.approvalTab.goto(url);
return env.approvalTab; return env.approvalTab;
@@ -5035,116 +5028,6 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
); );
}); });
// 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 toolbar button then
// opens the approval, and the user answers it there
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
//
// This closes the approval window of request `key` and checks that the page
// has no answer. It then opens the approval again from the toolbar button.
// Playwright cannot click that button, so its page is opened in a tab, at the
// URL the extension put on the button.
async function closeAndReopen(env, key, view) {
const approvalWindow = await waitForApprovalWindow(env.ctx);
await visible(approvalWindow, view);
await approvalWindow.close();
const outcome = await settleRequest(env.dapp, key, 2000);
assert(
outcome.settled === "pending",
"closing the approval window answered the page: " +
JSON.stringify(outcome),
);
const url = await toolbarPopupUrl(env);
assert(
url.includes("?approval="),
"the toolbar button does not open the pending approval: " + url,
);
const reopened = await env.ctx.newPage();
await reopened.goto(url);
await visible(reopened, view);
return reopened;
}
// The stub node answers every nonce request with 0, and the wallet refuses a
// nonce it has already broadcast. A run can therefore approve one dApp
// transaction only, so here the signature is approved and the transaction
// rejected.
test("a closed signature approval is signed from the toolbar button (#673)", async (env) => {
await startRequest(env.dapp, "sign-closed", "personal_sign", [
SIGN_HEX,
env.expectedAddress,
]);
const popup = await closeAndReopen(
env,
"sign-closed",
"#view-approve-sign",
);
let outcome;
try {
await popup.fill("#approve-sign-password", PASSWORD);
await clickAndClose(popup, "#btn-approve-sign");
outcome = await settleRequest(env.dapp, "sign-closed");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyMessage(getBytes(SIGN_HEX), outcome.result),
);
assert(
recovered === env.expectedAddress,
"the personal_sign signature recovers to " +
recovered +
", not to the approved address " +
env.expectedAddress,
);
const url = await toolbarPopupUrl(env);
assert(
!url.includes("?approval="),
"the toolbar button still opens the answered approval: " + url,
);
});
test("a closed transaction approval is rejected from the toolbar button (#673)", async (env) => {
const before = env.routeOpts.broadcastTransactions.length;
await startRequest(env.dapp, "tx-closed", "eth_sendTransaction", [
{
from: env.expectedAddress,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const popup = await closeAndReopen(env, "tx-closed", "#view-approve-tx");
try {
await clickAndClose(popup, "#btn-reject-tx");
await assertUserRejection(
env.dapp,
"tx-closed",
"the transaction rejected from the toolbar button",
);
} finally {
await closeApprovalPages(env.ctx);
}
assert(
env.routeOpts.broadcastTransactions.length === before,
"a rejected transaction still reached the RPC",
);
const url = await toolbarPopupUrl(env);
assert(
!url.includes("?approval="),
"the toolbar button still opens the answered approval: " + url,
);
});
// The extension used to centre every approval window on the last focused // The extension used to centre every approval window on the last focused
// window. Centred on another approval window, the new one landed where the // window. Centred on another approval window, the new one landed where the
// browser refused to create it, and the request failed with no window at all // browser refused to create it, and the request failed with no window at all
@@ -5188,8 +5071,8 @@ test("a prompt raised while another approval window has focus opens its own (#29
}); });
await visible(txWindow, "#view-approve-tx"); await visible(txWindow, "#view-approve-tx");
await clickAndClose(txWindow, "#btn-reject-tx"); // Closing a window is refusing its prompt, so both answer 4001.
await clickAndClose(signWindow, "#btn-reject-sign"); await closeApprovalPages(env.ctx);
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt"); await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt"); await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
}); });
@@ -5796,11 +5679,9 @@ async function main() {
// for the new one. After a test that passed, its windows get five // for the new one. After a test that passed, its windows get five
// seconds to close, and one still open then fails the test: it should // seconds to close, and one still open then fails the test: it should
// have closed itself, and closing it here unreported would hide that. // have closed itself, and closing it here unreported would hide that.
// After a test that already failed, they are closed unreported. That // After a test that already failed, they are closed unreported, so
// rejects a site-connection or network-switch prompt it left // that a prompt it left unanswered does not refuse the next one from
// unanswered, so the prompt does not refuse the next one from its // its site.
// site. A transaction or signature approval stays pending, and can
// fail the tests after it.
if (!failure) { if (!failure) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
let open; let open;