Author SHA1 Message Date
sneak cce6640d23 fix: the swap decoder names where a WRAP_ETH pays and shows a V4 step's ETH as a maximum when the router may hold ETH (closes #665)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A `WRAP_ETH` that sets the input side and pays its WETH to anyone but
the router names that recipient on its `Moved to` line.

V4 pays a `SETTLE` of ETH from the router's own ETH, whichever payer it
names. When an earlier step may leave ETH in the router, the ETH that
the user pays a later V4 step gets lines of its own. Their
`Moved from the sender` line reads `Up to <amount>`, or
`Unknown (could not be decoded)` when the amount is not known. The
decoder never subtracts the router's ETH, so no line shows less ETH
than can leave the wallet. Without such an earlier step, the lines read
as before.

Model: opus-5-5
2026-10-27 01:46:35 +00:00
clawbot 33ab99b7be 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
Closing a transaction or signature approval window no longer rejects the request; only Cancel does. The browser can close that window when the user clicks away, for example to copy the password from a password manager. The request stays pending and the site gets no answer. Pressing the toolbar button opens the oldest such request, unless an approved attempt still holds it. A Reject the background refuses keeps the window open with the background's message. The worker points the toolbar back at the wallet when it starts.

While such a request waits, the transaction slot stays held, so another site's eth_sendTransaction gets -32002. An address switch still rejects pending approvals with 4001.

Model: opus-5-5
2026-10-27 00:56:36 +01:00
clawbot b58ecec1f9 fix: AutistMask does not work in Firefox private windows (closes #646)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Firefox gives an extension page in a private window locks of its own, so a popup there could save between the background's read and its write and undo a network switch the background saved. `manifest/firefox.json` now sets `"incognito": "not_allowed"`, so such a page cannot read or write extension storage, and `tests/manifest.test.js` pins the key. README and the user guide say AutistMask does not work in Firefox private windows. Chrome needs no change: in its default incognito mode an extension page shares the background's lock.

A Firefox user on "Never remember history" can no longer run AutistMask at all.

Model: opus-5-5
2026-10-26 23:51:22 +01:00
12 changed files with 1254 additions and 279 deletions
+132 -101
View File
@@ -512,7 +512,10 @@ 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). [#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.
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:
@@ -521,9 +524,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 one prompt is driven at the URL the extension itself puts on click. So that prompt, and an approval reopened after its window closed, are
the action — the same page and the same approval id, but whether a real opened in a tab at the URL the extension puts on the action. Whether a real
toolbar click shows it is not observable here. toolbar click shows that page 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
@@ -642,15 +645,24 @@ 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 popup load, the StateRecovery screen (the same cases as the Chrome It covers:
suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` - popup load
rejected and then approved, `eth_sendTransaction`, and a closed approval window - the StateRecovery screen, with the same cases as the Chrome suite
rejecting with EIP-1193 4001 — driven through the real content script, - wallet creation through the UI
background page and approval windows. The popup open all along is required to - the Add Token screen
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop - `eth_requestAccounts` from a dApp
it once the switch back is. The `personal_sign` prompt is also required to fit - `personal_sign` from a dApp
its window without scrolling sideways, with the extension's pages at 170% zoom. - `wallet_switchEthereumChain` from a dApp, rejected and then approved
- `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
@@ -1253,7 +1265,9 @@ see Transaction Decoding), do not always read as a number. They can read:
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their `Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows
its `amountOut`, the exact amount it buys. its `amountOut`, the exact amount it buys. When the router may hold ETH from
an earlier step, the `Moved from the sender` line of ETH that the user pays a
V4 step also reads `Up to <amount>` (see Transaction Decoding).
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -1494,16 +1508,16 @@ 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 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 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 `withLock()` in `src/shared/browserApi.js`. Every page of the extension and its
background share it. `manifest/firefox.json` sets `"incognito": "not_allowed"`, background share it, except a page in a Firefox private window, which has locks
so no page runs in a Firefox private window. Firefox would give a page there of its own. `manifest/firefox.json` sets `"incognito": "not_allowed"`, so such a
locks of its own. In Chrome, a page opened from an incognito window still runs page cannot read or write extension storage. In Chrome, a page opened from an
outside incognito and shares the lock. The popup's `saveState()`, the incognito window still runs outside incognito and shares the lock. The popup's
background's `updateState()` and the StateRecovery reset all take it, so no `saveState()`, the background's `updateState()` and the StateRecovery reset all
write lands between another write's read and its write, where it would be lost. take it, so no write lands between another write's read and its write, where it
Chrome has it from version 69, older than any Chrome that runs a Manifest V3 would be lost. Chrome has it from version 69, older than any Chrome that runs a
extension, in pages and in the service worker; Firefox from version 96, older Manifest V3 extension, in pages and in the service worker; Firefox from version
than the 115 `manifest/firefox.json` declares. Where it is missing, a write 96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
fails rather than going ahead without it. write fails rather than going ahead without it.
Version 1 is the shape that shipped before versions existed, so a stored record Version 1 is the shape that shipped before versions existed, so a stored record
with no `schemaVersion` is version 1 rather than a defect: it loads normally and with no `schemaVersion` is version 1 rather than a defect: it loads normally and
@@ -2644,8 +2658,14 @@ 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)
- Popup window closed without answering → the request is rejected with - "Reject" after "Confirm", while the transaction is being sent → the
EIP-1193 code 4001 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
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`)
@@ -2734,8 +2754,11 @@ 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)
- Popup window closed without answering → the request is rejected with - "Reject" after "Sign", while the signature is being checked → the
EIP-1193 code 4001 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.
#### NetworkApproval (`approve-network`) #### NetworkApproval (`approve-network`)
@@ -3456,81 +3479,89 @@ Currently supported:
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the
rest taken to another address, reads `5000.0000 USDT`. A later step paid from rest taken to another address, reads `5000.0000 USDT`. V4 pays a `SETTLE` of
the router spends what the router holds and takes nothing, and so does a V2 ETH from the router's own ETH, whichever payer it names. So when the router
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is may hold ETH from an earlier step (see above), the ETH that the user pays a V4
added to the total while both are quantities: a V3 exact-in of 600 USDT and a step gets lines of its own (see below). A later step paid from the router
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When spends what the router holds and takes nothing, and so does a V2 exact-in
the decoder does not know a take's amount, the total is not known and there is whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in
`Unlimited` included, stands as the `Amount` figure in place of the total, of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder
which it does only in the plain shape of Uniswap's own routes: it comes before does not know a take's amount, the total is not known and there is no `Amount`
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited`
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, included, stands as the `Amount` figure in place of the total, which it does
the router may not let it fail, no step before it takes its token, a only in the plain shape of Uniswap's own routes: it comes before the step that
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and
and the decoder reads every step after it in full. Permit2 then lets the there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may
router take no more of the token than that amount, so the permit covers every not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM`
take of it. A step before the permit takes under whatever allowance the router of it included, or a token the decoder does not know, and the decoder reads
already had, so it may take more than the permit's amount, which would then every step after it in full. Permit2 then lets the router take no more of the
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of token than that amount, so the permit covers every take of it. A step before
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 the permit takes under whatever allowance the router already had, so it may
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A take more than the permit's amount, which would then read lower than what
later permit or batch may raise what Permit2 lets the router take, and so may leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the
a step the decoder does not read in full: a command the decoder does not read router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000
(listed at the end of this section), a step whose input it cannot read, in USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before batch may raise what Permit2 lets the router take, and so may a step the
the step that sets the input side or after it, leaves `Token In` and the decoder does not read in full: a command the decoder does not read (listed at
`Amount` line as they read with no permit, unless it lets a spender other than the end of this section), a step whose input it cannot read, in whole or in
the router take `Token In` (see below): a permit of 5,000 DAI to another part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with that sets the input side or after it, leaves `Token In` and the `Amount` line
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI as they read with no permit, unless it lets a spender other than the router
to the router. When a permit's amount stands and the step that sets the input take `Token In` (see below): a permit of 5,000 DAI to another address and then
side spends a token other than the permit's, or one the decoder does not know, a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`,
that step's amount gets lines of its own, unless it states no amount and the and so do that exact-in and then a permit of 5,000 DAI to the router. When a
user pays it none of its input token. Each take the total does not count gives permit's amount stands and the step that sets the input side spends a token
three lines after the `Amount` line (after `Token In` when there is none): one other than the permit's, or one the decoder does not know, that step's amount
of a token other than `Token In`; one of `Token In` after an amount that is gets lines of its own, unless it states no amount and the user pays it none of
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on its input token. Each take the total does not count gives three lines after
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one the `Amount` line (after `Token In` when there is none): one of a token other
whose amount the decoder does not know. When there is no `Amount` line, each than `Token In`; one of `Token In` after an amount that is not a quantity (a
take the total counts gets them too, so that every amount the decoder reads is V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line,
shown: the amount the step that sets the input side states, when it is a or no stated amount, as of a V4 exact-out step); one whose amount the decoder
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is does not know; and one of ETH that the user pays a V4 step when the router may
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose hold ETH. When there is no `Amount` line, each take the total counts gets them
ETH comes from the transaction's value), and each later take of `Token In` too, so that every amount the decoder reads is shown: the amount the step that
added to it. A step that sets the input side and is paid from the router takes sets the input side states, when it is a quantity and the user pays that step
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's
lines for both amounts. When the step that sets the input side states no value), and each later take of `Token In` added to it. A step that sets the
amount, the user pays it some of its input token, and there is no `Amount` input side and is paid from the router takes nothing from the user's wallet
line, that step gets them too whenever another take's lines give a figure, or and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When
the router, on its `Paid to` line, so that figure cannot read as all that the step that sets the input side states no amount, the user pays it some of
leaves. When it states no amount and the user pays it none of its input token, its input token, and there is no `Amount` line, that step gets them too
as when the router pays it, that step takes none of that token from the user's whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of
wallet and never gets them, with a permit or without. They come in step order, its token gives one, on the lines below or, into the router, on its `Paid to`
except that the step that sets the input side comes first when it has them, line, so that figure cannot read as all that leaves. When it states no amount
followed, when there is no `Amount` line, by the other takes the total counts: and the user pays it none of its input token, as when the router pays it, that
`Moved from the sender`, its amount, worded as the `Amount` line words one, or step takes none of that token from the user's wallet and never gets them, with
a permit or without. They come in step order, except that the step that sets
the input side comes first when it has them, followed, when there is no
`Amount` line, by the other takes the total counts: `Moved from the sender`,
its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it; `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. For ETH that the user
does not know gives the `Moved from the sender` line alone. A step may also pays a V4 step when the router may hold ETH, `Moved from the sender` reads
take from the user's wallet what the decoder did not read: a V4 step whose `Up to <amount>` for a known amount. For a `WRAP_ETH` that pays its WETH to
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the anyone but the router, `Moved to` names that recipient instead: `The sender`
router still runs; a V2 or V3 exact-in or exact-out step or a for `address(1)`, and otherwise the address. A token the decoder does not know
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose gives the `Moved from the sender` line alone. A step may also take from the
path is shorter than one token address or has bytes after its last hop; every user's wallet what the decoder did not read: a V4 step whose input or one of
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands; whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs;
and every step it fails on. Each such step gives one `Moved from the sender` a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input
line among those, reading `Unknown (could not be decoded)`. That line, or any it cannot read, such as a V3 step whose path is shorter than one token address
line for a token the decoder does not know, leaves no `Amount` line, whose or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the `EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such
permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into step gives one `Moved from the sender` line among those, reading
the router, in step order, gives three lines: `Moved from the sender`, the `Unknown (could not be decoded)`. That line, or any line for a token the
amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the decoder does not know, leaves no `Amount` line, whose figure might be too low,
token's address as the `Token In` line does; and `Moved to`, which reads and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
+10
View File
@@ -208,6 +208,9 @@ password.
3. Click "Load Temporary Add-on". 3. Click "Load Temporary Add-on".
4. Select `dist/firefox/manifest.json`. 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 ## Getting Started
### Creating a New Wallet ### Creating a New Wallet
@@ -350,6 +353,13 @@ 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
+73 -67
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 either being prepared // the transaction is populated, so the other request is being prepared, is on
// or on screen. It does not claim the other one is displayed yet, because for // screen, or waits with its window closed. It does not claim the other one is
// the length of one network round trip it is not. // displayed yet, because for 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,14 +383,26 @@ async function proxyRpc(method, params) {
return json.result; return json.result;
} }
// The site-connection approval the toolbar popup is set to open, or null while // The approval the toolbar popup is set to open, or null while it opens the
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup(). // wallet. Set only by updateToolbarPopup() and showInToolbarPopup().
let toolbarPopupApprovalId = null; let toolbarPopupApprovalId = null;
function resetPopupUrl() { // Point the toolbar popup at the oldest pending approval whose window closed,
toolbarPopupApprovalId = null; // 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.
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({ popup: "src/popup/index.html" }); actionNs.setPopup({
popup: closed
? "src/popup/index.html?approval=" + closed.id
: "src/popup/index.html",
});
} }
} }
@@ -420,15 +432,16 @@ function settleApproval(id, result, options) {
// approval stops being alive. // approval stops being alive.
releaseTxApprovalSlotFor(id); releaseTxApprovalSlotFor(id);
approval.resolve(result); approval.resolve(result);
resetPopupUrl(); updateToolbarPopup();
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: the window was closed, or could not be opened at all. A tx or sign // answered: a site-connection or network-switch window was closed, or the
// approval answers the requesting page in EIP-1193 shape; a site-connection or // window could not be opened at all. An address switch in the wallet gives up
// network-switch approval answers its handler in the shape the popup's // pending approvals too. A tx or sign approval answers the requesting page in
// decision has, as a refusal. // EIP-1193 shape; a site-connection or network-switch approval answers its
// 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 } };
@@ -436,8 +449,9 @@ function abandonedResult(approval, code, message) {
return { approved: false, remember: false }; return { approved: false, remember: false };
} }
// A window the user closed without answering is a refusal by the user, which // A site-connection or network-switch window the user closed without answering
// is 4001 and the wording every other rejection path already uses. // is a refusal by the user, which is 4001 and the wording every other rejection
// 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.";
@@ -465,31 +479,16 @@ 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. // Nothing was broadcast, so the next attempt may claim it. If the user closed
// // 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;
if (approval.windowClosed) { updateToolbarPopup();
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.
@@ -712,9 +711,7 @@ 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. When their window closes, the windows.onRemoved listener below // disconnect, and across their window closing, until the user answers them.
// 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];
@@ -742,9 +739,7 @@ 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") {
// When its window closes, windows.onRemoved rejects it, // It stays pending until the user answers 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 });
@@ -1297,7 +1292,7 @@ async function broadcastAccountsChanged() {
windowsRemove(approval.windowId).catch(() => {}); windowsRemove(approval.windowId).catch(() => {});
} }
} }
resetPopupUrl(); updateToolbarPopup();
// 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.
@@ -1489,36 +1484,44 @@ if (runtime.onStartup) {
} }
startBackgroundJobs(); startBackgroundJobs();
// When approval window is closed without a response, treat as rejection. // The toolbar popup keeps its URL when the worker stops. A worker that starts
// "Without a response" is the operative part: the popup stays open across the // holds no approvals, so the toolbar popup is pointed back at the wallet.
// verify and broadcast it is waiting on, so a user closing an apparently-hung updateToolbarPopup();
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real // A transaction or signature approval stays pending when its window closes.
// outcome to the page — and the window is recorded as gone, so that an attempt // The browser can close that window when the user clicks away from it, for
// which then fails retryably settles instead of waiting in a window that no // example to copy the password from a password manager. The approval is marked
// longer exists. // 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.
// //
// A site-connection or network-switch approval whose popup connected its port // This listener rejects a site-connection or network-switch approval whose
// is not decided here. That popup approves and closes in the same breath, and // window closes before its popup connected its port. Once the port is
// this event races the decision on a channel of its own — the same race the // connected, the approval is left to the port. That popup approves and closes
// port exists to end. Its port disconnect says the same thing this event does, // in the same breath, and this event races the decision on a channel of its
// in an order that is defined, so the disconnect is left to say it. The window // own — the same race the port exists to end. Its port disconnect says the
// closing before any port connected is the one case with nothing else to speak // same thing this event does, in an order that is defined, so the disconnect is
// for it, and is rejected here so the dApp is not left waiting on a window that // left to say it. The window closing before any port connected is the one case
// is gone. // with nothing else to speak for it, and is rejected here so the dApp is not
// 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;
const decidedOnPort = if (approval.type === "tx" || approval.type === "sign") {
approval.type !== "tx" && approval.type !== "sign"; approval.windowClosed = true;
if (decidedOnPort && approval.portConnected) continue; updateToolbarPopup();
const rejection = abandonedResult( continue;
approval, }
APPROVAL_REJECTED_CODE, if (approval.portConnected) continue;
APPROVAL_REJECTED_MESSAGE, settleApproval(
id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
); );
if (!settleApproval(id, rejection)) approval.windowClosed = true;
} }
}); });
} }
@@ -1663,7 +1666,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
return false; return false;
} }
return true; // The popup waits for this answer before it closes its window.
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
@@ -1863,7 +1868,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
return false; return false;
} }
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
+3 -3
View File
@@ -281,9 +281,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. The connection prompt can also // no resize event, and the page stops there. An approval can also show in the
// show in the toolbar popup, whose window is the user's browser window; that // toolbar popup, whose window is the user's browser window; that one is left
// one is left alone. // 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;
+22 -7
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 { notify, runtimeApi, sendMessage } = require("../../shared/browserApi"); const { runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
@@ -1142,12 +1142,21 @@ function init(_ctx) {
} }
}); });
$("btn-reject-tx").addEventListener("click", () => { // The window closes only once the background has the rejection. A message
notify({ // 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.
$("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();
}); });
@@ -1270,12 +1279,18 @@ function init(_ctx) {
if (outcome.retryable) setSignButtonBusy(false); if (outcome.retryable) setSignButtonBusy(false);
}); });
$("btn-reject-sign").addEventListener("click", () => { // As for the transaction's Reject above. After Sign, the background
notify({ // refuses a rejection while the signature is being checked.
$("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();
}); });
} }
+77 -26
View File
@@ -1244,6 +1244,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// comes from the transaction's value. A step paid from the router // comes from the transaction's value. A step paid from the router
// takes nothing from the sender. // takes nothing from the sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
let permit = null; let permit = null;
@@ -1336,15 +1340,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}; };
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
// that sets the input side, as { token, amount }, in step order. // that sets the input side, as { token, amount, atMost }, in step
// After the loop each joins the Amount line or gets lines of its own // order. After the loop each joins the Amount line or gets lines of
// (see permitStands). // its own (see permitStands). A take with `atMost` set takes at most
// its amount, and always gets lines of its own.
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes // of `token` from the sender, or at most that when `atMost` is set. A
// nothing. // V2 exact-in of ALREADY_PAID takes nothing.
const addUserPaid = (token, amount) => { const addUserPaid = (token, amount, atMost = false) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount }); if (amount !== ALREADY_PAID) {
userPaid.push({ token, amount, atMost });
}
}; };
// A step may take from the sender what this decoder did not read: a // A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
@@ -1787,6 +1794,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
inputPaidByUser = true; inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} }
addPayee(w.recipient); addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH. // CONTRACT_BALANCE wraps all the router's ETH.
@@ -1815,6 +1825,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished; const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own
// ETH, whichever payer it names. When the router may hold
// ETH from an earlier step, that ETH may pay some of the
// ETH that the user pays this step. The sender then pays
// at most the step's amount.
const routerMayHoldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
if (v4) { if (v4) {
// Each SETTLE paid from the router spends the amount // Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays. // decodeV4Swap() says it pays.
@@ -1905,7 +1923,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (v4) { if (v4) {
for (const p of v4.paidByUser) { for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue; if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount); addUserPaid(
p.currency,
p.amount,
routerMayHoldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
} }
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
@@ -2016,7 +2040,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// being of another token or coming after an amount that is not a // being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the // lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. // total unknown, with no figure. A take of at most its amount (see
// userPaid) is never added, and gets lines of its own too.
// //
// A PERMIT2_PERMIT names Token In only when its amount stands as the // A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the // Amount figure in place of the total, which it does only in the
@@ -2051,14 +2076,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as // What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read // { token, amount, atMost, recipient }. A null token is a step this
// (see spentUnread()), or a token it does not know. // decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient).
const spentFromSender = []; const spentFromSender = [];
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, as // The takes from the sender that `total` adds up, each a quantity, in
// { token, amount }. When there is no Amount line, each gets lines of // the shape of `spentFromSender`. When there is no Amount line, each
// its own (see below). The amount of a step that set the input side // gets lines of its own (see below). The amount of a step that set
// and is paid from the router counts in `total` but is not among them. // the input side and is paid from the router counts in `total` but is
// not among them.
const inTotal = []; const inTotal = [];
// Whether the step that set the input side counts as a take from the // Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts // sender, which may get lines of its own. The amount it stated counts
@@ -2073,7 +2101,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) { if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount; total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) { if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount }); inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
@@ -2081,7 +2113,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) { } else if (hasFirstTake) {
// The step that set the input side spent a token other than the // The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own. // permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount }); spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (permitStands) inputToken = permit.token; if (permitStands) inputToken = permit.token;
for (const t of userPaid) { for (const t of userPaid) {
@@ -2089,6 +2125,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
if ( if (
ofTokenIn && ofTokenIn &&
!t.atMost &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" typeof t.amount === "bigint"
) { ) {
@@ -2193,8 +2230,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see // V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be // tests come first — the sentinels are not bigints and cannot be
// compared with one. // compared with one. `atMost` says the amount is a maximum (see
const spentText = (amount, info) => { // userPaid).
const spentText = (amount, info, atMost = false) => {
if (amount === OPEN_DELTA) { if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} }
@@ -2207,7 +2245,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount >= MAX_UINT160) { if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" }; return { raw: "Unlimited", display: "Unlimited" };
} }
if (hasV2OrV3ExactOut) { if (hasV2OrV3ExactOut || atMost) {
// A V2 or V3 exact-out swap spends at most this figure, // A V2 or V3 exact-out swap spends at most this figure,
// whichever steps gave it (an amountInMax, the WRAP_ETH of a // whichever steps gave it (an amountInMax, the WRAP_ETH of a
// swap paid in ETH, a permit, or several steps' total), so it // swap paid in ETH, a permit, or several steps' total), so it
@@ -2260,20 +2298,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape // What steps take from the sender beyond the Amount line, in the shape
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets // decoder does not know is said so, and a step it did not read gets
// that one line alone. // that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient;
// `MSG_SENDER` means the sender.
for (const s of spentFromSender) { for (const s of spentFromSender) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: present(s.amount) value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources)).display ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources)); details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({ if (!present(s.recipient)) {
label: "Moved to", details.push({
value: "The swap step that spends it", label: "Moved to",
}); value: "The swap step that spends it",
});
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
details.push({
label: "Moved to",
value: s.recipient,
address: s.recipient,
});
}
} }
} }
+134
View File
@@ -12,6 +12,7 @@ const {
cleanupPopup, cleanupPopup,
settle, settle,
unversionedValidProfile, unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
afterEach(cleanupPopup); afterEach(cleanupPopup);
@@ -174,3 +175,136 @@ 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([]);
},
);
+294 -47
View File
@@ -241,10 +241,11 @@ 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. A site approval // Every URL the background put on the browser action. When a test gives
// raised through action.openPopup() opens no window at all, so this is // the URL the action holds before the worker starts, that URL comes
// the only place its id appears. // first. A site approval raised through action.openPopup() opens no
const actionPopups = []; // window at all, so this is the only place its id appears.
const actionPopups = opts.toolbarPopup ? [opts.toolbarPopup] : [];
const openPopup = jest.fn(() => Promise.resolve()); const openPopup = jest.fn(() => Promise.resolve());
global.chrome = { global.chrome = {
@@ -774,10 +775,9 @@ 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
// would normally leave the approval standing for a retry. There is no // leaves the approval standing for a retry. The toolbar popup opens it, so
// window left to retry in, so leaving it standing answers the requesting // the user can still retry it, and answering it frees the slot.
// 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 retried from the toolbar popup, and frees the next request", async () => {
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,11 +808,28 @@ describe("one transaction approval at a time", () => {
stalled.resolve(); stalled.resolve();
await settle(); await settle();
expect(first.result()).toEqual({ expect(first.result()).toBeNull();
error: { code: 4001, message: "User rejected the request." }, expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
}); "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();
@@ -853,8 +870,10 @@ describe("one transaction approval at a time", () => {
const first = bg.requestTx(); const first = bg.requestTx();
await settle(); await settle();
// The user closes the approval window, which rejects it. bg.send(
bg.closeWindow(1); { type: "AUTISTMASK_TX_RESPONSE", id: first.id(), approved: false },
{ 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." },
@@ -897,7 +916,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;
bg.requestTx(); const pending = 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);
@@ -906,11 +925,23 @@ describe("the worker keeps running while an approval is pending", () => {
tick(); tick();
expect(calls).toHaveBeenCalledTimes(1); expect(calls).toHaveBeenCalledTimes(1);
// The user closes the approval window, which rejects it. // A closed window leaves the approval pending, so the calls go on.
bg.closeWindow(1); bg.closeWindow(1);
await settle(); await settle();
tick(); tick();
expect(calls).toHaveBeenCalledTimes(1); expect(calls).toHaveBeenCalledTimes(2);
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();
@@ -1841,6 +1872,237 @@ 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
@@ -1961,20 +2223,6 @@ 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();
@@ -2584,15 +2832,12 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// The `isSite &&` half of that skip, which is what keeps it from reaching // That skip must not reach a tx or sign approval. The popup connects its
// a tx or sign approval. The popup connects its port in show() before it // port in show() before it knows the approval's type, and the background
// knows the approval's type, and the background sets portConnected without // sets portConnected without looking at the type either, so a tx approval
// looking at the type either, so a tx approval in the fallback window // carries the flag too. If the window event skipped it, windowClosed would
// carries the flag too. Without the conjunct the window event would skip // never be set, and the toolbar popup would never open the approval.
// it, windowClosed would never be set, releaseApproval() would never settle test("a tx window closed with the port connected is still opened by the toolbar popup", async () => {
// 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();
@@ -2600,9 +2845,10 @@ 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()).toEqual({ expect(pending.result()).toBeNull();
error: { code: 4001, message: "User rejected the request." }, expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
}); "src/popup/index.html?approval=" + pending.id(),
);
}); });
}); });
@@ -2994,13 +3240,14 @@ 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: // The request waits on the second window rather than failing. Once
// closing that window is refusing the prompt. // that window closes, the toolbar popup opens the request.
expect(sign.result()).toBeNull(); expect(sign.result()).toBeNull();
bg.closeWindow(2); bg.closeWindow(2);
await settle(); await settle();
expect(sign.result()).toEqual({ expect(sign.result()).toBeNull();
error: { code: 4001, message: "User rejected the request." }, expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
}); "src/popup/index.html?approval=" + sign.id(),
);
}); });
}); });
+41 -8
View File
@@ -1035,11 +1035,14 @@ 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(
"closing an approval window rejects the request with 4001", "a closed approval window leaves the request pending until it is rejected",
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", [
@@ -1053,15 +1056,45 @@ 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 — call site 4 in the issue, and the // approval to be matched against. Without that id the toolbar button
// reason suppressing that write-back turns this step red. // never opens the approval, and this step fails.
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(
env.server.broadcast.length === before, outcome.settled === "pending",
"a closed approval window still put a transaction on the node", "closing the approval window answered the page: " +
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);
}
}, },
); );
+135 -16
View File
@@ -4110,11 +4110,12 @@ 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 resetPopupUrl() on // disconnects its port, and src/background/index.js calls
// that disconnect; issued too early, the next request's setPopup() is undone // updateToolbarPopup() on that disconnect; issued too early, the next
// by the previous popup's teardown, chrome.action.openPopup() then rejects, // request's setPopup() is undone by the previous popup's teardown,
// and the fallback window it opens dismisses the popup that was just raised — // chrome.action.openPopup() then rejects, and the fallback window it opens
// which settles the fresh approval as a rejection before it can be seen. // dismisses the popup that was just raised — which settles the fresh approval
// 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;
@@ -4140,6 +4141,17 @@ 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) {
@@ -4150,12 +4162,7 @@ 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 env.page.evaluate( const url = await toolbarPopupUrl(env);
() =>
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;
@@ -5027,6 +5034,116 @@ 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
@@ -5070,8 +5187,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");
// Closing a window is refusing its prompt, so both answer 4001. await clickAndClose(txWindow, "#btn-reject-tx");
await closeApprovalPages(env.ctx); await clickAndClose(signWindow, "#btn-reject-sign");
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");
}); });
@@ -5678,9 +5795,11 @@ 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, so // After a test that already failed, they are closed unreported. That
// that a prompt it left unanswered does not refuse the next one from // rejects a site-connection or network-switch prompt it left
// its site. // unanswered, so the prompt does not refuse the next one from its
// 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;
+1 -1
View File
@@ -169,7 +169,7 @@ describe("declared icons", () => {
// save could land between the background's read and its write (README, // save could land between the background's read and its write (README,
// "Stored state and its version"). // "Stored state and its version").
describe("private windows", () => { describe("private windows", () => {
test("firefox never runs AutistMask in a private window", () => { test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed"); expect(readManifest("firefox").incognito).toBe("not_allowed");
}); });
}); });
+332 -3
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
]; ];
// execute() of these command bytes and inputs, decoded as sent by // execute() of these command bytes and inputs, decoded as sent by
// the user. // the user to `router`.
function decodeSteps(commands, inputs) { function decodeSteps(commands, inputs, router = ROUTER_ADDR) {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
commands.map(() => "uint8"), commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs, inputs,
9999999999n, 9999999999n,
); );
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); return uniswap.decode(data, router, "0x1", {}, USER_ADDR);
} }
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the // The first step: a V3 exact-in of 600 USDT for USDC, paid by the
@@ -2424,6 +2424,335 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that
// sets the input side and pays its WETH to anyone but the router
// names that recipient on its Moved to line. On next each row read
// The swap step that spends it.
const ONE_ETH = 1000000000000000000n;
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
// The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`.
const wrapLines = (to) => [
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", to],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines("The sender"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V3_600,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a
// SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The two
// rows marked "reads as on next" read the same on next. On next every
// other row added that ETH to the Amount line, or read it as an exact
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user.
const v4EthForUsdc = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, 0n, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. // USDT for USDC.
const v3ExactOut = (amountInMax) => const v3ExactOut = (amountInMax) =>