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

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

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

Model: opus-5-5
2026-10-26 21:40:57 +00:00
15 changed files with 150 additions and 698 deletions
+82 -98
View File
@@ -512,10 +512,9 @@ window of its own, and the password is required to be absent from every message
the approval window sends to the background — with the message that would carry the approval window sends to the background — with the message that would carry
it required to be present, so that check cannot pass by observing nothing. That it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). Closing a transaction [#157](https://git.eeqj.de/sneak/AutistMask/issues/157). A transaction or
or signature approval window is required to leave the dApp's request unanswered. signature approval whose window is closed is required to leave the page without
The suite then opens that approval at the URL the toolbar button holds, and an answer. The suite then answers it from the page the toolbar button opens.
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:
@@ -1265,9 +1264,7 @@ 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. When the router may hold ETH from its `amountOut`, the exact amount it buys.
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
@@ -1508,12 +1505,11 @@ wrote last, the record says which build's shape it is in.
Each write reads the record, changes it and writes it back, holding a lock from 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, except a page in a Firefox private window, which has locks background share it, except a page in a Firefox private window, which Firefox
of its own. `manifest/firefox.json` sets `"incognito": "not_allowed"`, so such a gives locks of its own (see https://git.eeqj.de/sneak/AutistMask/issues/646).
page cannot read or write extension storage. In Chrome, a page opened from an The popup's `saveState()`, the background's `updateState()` and the
incognito window still runs outside incognito and shares the lock. The popup's StateRecovery reset all take it, so among the pages and the background that
`saveState()`, the background's `updateState()` and the StateRecovery reset all share it, no write lands between another write's read and its write, where it
take it, so no write lands between another write's read and its write, where it
would be lost. Chrome has it from version 69, older than any Chrome that runs a would be lost. Chrome has it from version 69, older than any Chrome that runs a
Manifest V3 extension, in pages and in the service worker; Firefox from version Manifest V3 extension, in pages and in the service worker; Firefox from version
96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a 96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
@@ -2658,8 +2654,6 @@ view would leave a wallet one click from deletion.
Data Model) → full-sentence error on the error line saying why, nothing Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change) signed (no screen change)
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- "Reject" after "Confirm", while the transaction is being sent → the
background's message on the error line, no screen change
- Popup window closed without answering → the request stays pending, and the - Popup window closed without answering → the request stays pending, and the
site gets no answer. The browser can close this window when the user 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 clicks away from it, for example to copy the password from a password
@@ -2754,8 +2748,6 @@ view would leave a wallet one click from deletion.
- "Sign" (wrong password, or a signing failure) → error line, no screen - "Sign" (wrong password, or a signing failure) → error line, no screen
change change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- "Reject" after "Sign", while the signature is being checked → the
background's message on the error line, no screen change
- Popup window closed without answering → the request stays pending, as on - Popup window closed without answering → the request stays pending, as on
TxApproval. Until the request is answered, the toolbar button opens this TxApproval. Until the request is answered, the toolbar button opens this
screen instead of the wallet. screen instead of the wallet.
@@ -3479,89 +3471,81 @@ 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`. V4 pays a `SETTLE` of rest taken to another address, reads `5000.0000 USDT`. A later step paid from
ETH from the router's own ETH, whichever payer it names. So when the router the router spends what the router holds and takes nothing, and so does a V2
may hold ETH from an earlier step (see above), the ETH that the user pays a V4 exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is
step gets lines of its own (see below). A later step paid from the router added to the total while both are quantities: a V3 exact-in of 600 USDT and a
spends what the router holds and takes nothing, and so does a V2 exact-in V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When
whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the the decoder does not know a take's amount, the total is not known and there is
total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount,
of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder `Unlimited` included, stands as the `Amount` figure in place of the total,
does not know a take's amount, the total is not known and there is no `Amount` which it does only in the plain shape of Uniswap's own routes: it comes before
line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
included, stands as the `Amount` figure in place of the total, which it does transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
only in the plain shape of Uniswap's own routes: it comes before the step that the router may not let it fail, no step before it takes its token, a
sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and `PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know,
there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may and the decoder reads every step after it in full. Permit2 then lets the
not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` router take no more of the token than that amount, so the permit covers every
of it included, or a token the decoder does not know, and the decoder reads take of it. A step before the permit takes under whatever allowance the router
every step after it in full. Permit2 then lets the router take no more of the already had, so it may take more than the permit's amount, which would then
token than that amount, so the permit covers every take of it. A step before read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of
the permit takes under whatever allowance the router already had, so it may 2,000 USDT into the router, then a permit of 600 USDT to the router and a V3
take more than the permit's amount, which would then read lower than what exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A
leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the later permit or batch may raise what Permit2 lets the router take, and so may
router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000 a step the decoder does not read in full: a command the decoder does not read
USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or (listed at the end of this section), a step whose input it cannot read, in
batch may raise what Permit2 lets the router take, and so may a step the whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before
decoder does not read in full: a command the decoder does not read (listed at the step that sets the input side or after it, leaves `Token In` and the
the end of this section), a step whose input it cannot read, in whole or in `Amount` line as they read with no permit, unless it lets a spender other than
part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step the router take `Token In` (see below): a permit of 5,000 DAI to another
that sets the input side or after it, leaves `Token In` and the `Amount` line address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
as they read with no permit, unless it lets a spender other than the router `Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
take `Token In` (see below): a permit of 5,000 DAI to another address and then to the router. When a permit's amount stands and the step that sets the input
a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, side spends a token other than the permit's, or one the decoder does not know,
and so do that exact-in and then a permit of 5,000 DAI to the router. When a that step's amount gets lines of its own, unless it states no amount and the
permit's amount stands and the step that sets the input side spends a token user pays it none of its input token. Each take the total does not count gives
other than the permit's, or one the decoder does not know, that step's amount three lines after the `Amount` line (after `Token In` when there is none): one
gets lines of its own, unless it states no amount and the user pays it none of of a token other than `Token In`; one of `Token In` after an amount that is
its input token. Each take the total does not count gives three lines after not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
the `Amount` line (after `Token In` when there is none): one of a token other the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
than `Token In`; one of `Token In` after an amount that is not a quantity (a whose amount the decoder does not know. When there is no `Amount` line, each
V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, take the total counts gets them too, so that every amount the decoder reads is
or no stated amount, as of a V4 exact-out step); one whose amount the decoder shown: the amount the step that sets the input side states, when it is a
does not know; and one of ETH that the user pays a V4 step when the router may quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is
hold ETH. When there is no `Amount` line, each take the total counts gets them true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose
too, so that every amount the decoder reads is shown: the amount the step that ETH comes from the transaction's value), and each later take of `Token In`
sets the input side states, when it is a quantity and the user pays that step added to it. A step that sets the input side and is paid from the router takes
(a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder
its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give
value), and each later take of `Token In` added to it. A step that sets the lines for both amounts. When the step that sets the input side states no
input side and is paid from the router takes nothing from the user's wallet amount, the user pays it some of its input token, and there is no `Amount`
and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins line, that step gets them too whenever another take's lines give a figure, or
of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
the step that sets the input side states no amount, the user pays it some of the router, on its `Paid to` line, so that figure cannot read as all that
its input token, and there is no `Amount` line, that step gets them too leaves. When it states no amount and the user pays it none of its input token,
whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of as when the router pays it, that step takes none of that token from the user's
its token gives one, on the lines below or, into the router, on its `Paid to` wallet and never gets them, with a permit or without. They come in step order,
line, so that figure cannot read as all that leaves. When it states no amount except that the step that sets the input side comes first when it has them,
and the user pays it none of its input token, as when the router pays it, that followed, when there is no `Amount` line, by the other takes the total counts:
step takes none of that token from the user's wallet and never gets them, with `Moved from the sender`, its amount, worded as the `Amount` line words one, or
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`. For ETH that the user `Moved to`, which reads `The swap step that spends it`. A token the decoder
pays a V4 step when the router may hold ETH, `Moved from the sender` reads does not know gives the `Moved from the sender` line alone. A step may also
`Up to <amount>` for a known amount. For a `WRAP_ETH` that pays its WETH to take from the user's wallet what the decoder did not read: a V4 step whose
anyone but the router, `Moved to` names that recipient instead: `The sender` input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the
for `address(1)`, and otherwise the address. A token the decoder does not know router still runs; a V2 or V3 exact-in or exact-out step or a
gives the `Moved from the sender` line alone. A step may also take from the `PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose
user's wallet what the decoder did not read: a V4 step whose input or one of path is shorter than one token address or has bytes after its last hop; every
whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; `PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands;
a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input and every step it fails on. Each such step gives one `Moved from the sender`
it cannot read, such as a V3 step whose path is shorter than one token address line among those, reading `Unknown (could not be decoded)`. That line, or any
or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every line for a token the decoder does not know, leaves no `Amount` line, whose
`EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the
step gives one `Moved from the sender` line among those, reading permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into
`Unknown (could not be decoded)`. That line, or any line for a token the the router, in step order, gives three lines: `Moved from the sender`, the
decoder does not know, leaves no `Amount` line, whose figure might be too low, amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the
and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from token's address as the `Token In` line does; and `Moved to`, which reads
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
-3
View File
@@ -208,9 +208,6 @@ 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
-1
View File
@@ -4,7 +4,6 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"incognito": "not_allowed",
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", "content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
+8 -11
View File
@@ -161,9 +161,9 @@ let txApprovalSlot = null;
const APPROVAL_PENDING_CODE = -32002; const APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before // True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is being prepared, is on // the transaction is populated, so the other request is either being prepared
// screen, or waits with its window closed. It does not claim the other one is // or on screen. It does not claim the other one is displayed yet, because for
// displayed yet, because for the length of one network round trip it is not. // the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE = const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" + "AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" + " already in progress, so this one was not sent. Please finish that" +
@@ -390,11 +390,10 @@ let toolbarPopupApprovalId = null;
// Point the toolbar popup at the oldest pending approval whose window closed, // Point the toolbar popup at the oldest pending approval whose window closed,
// or at the wallet when there is none. The user can then answer that approval // 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 // 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 // were added, so the first one found is the oldest.
// running attempt is skipped until that attempt ends.
function updateToolbarPopup() { function updateToolbarPopup() {
const closed = Object.values(pendingApprovals).find( const closed = Object.values(pendingApprovals).find(
(approval) => approval.windowClosed && !approval.attemptInFlight, (approval) => approval.windowClosed,
); );
toolbarPopupApprovalId = closed ? closed.id : null; toolbarPopupApprovalId = closed ? closed.id : null;
if (actionNs && typeof actionNs.setPopup === "function") { if (actionNs && typeof actionNs.setPopup === "function") {
@@ -479,7 +478,6 @@ 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;
} }
@@ -488,7 +486,6 @@ function claimApproval(approval) {
// its window while the attempt ran, the toolbar popup opens it for that retry. // its window while the attempt ran, the toolbar popup opens it for that retry.
function releaseApproval(approval) { function releaseApproval(approval) {
approval.attemptInFlight = false; approval.attemptInFlight = false;
updateToolbarPopup();
} }
// 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.
@@ -1491,9 +1488,9 @@ updateToolbarPopup();
// A transaction or signature approval stays pending when its window closes. // 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 // The browser can close that window when the user clicks away from it, for
// example to copy the password from a password manager. The approval is marked // example to copy the password from a password manager. The approval is marked
// as closed, so the toolbar popup can open it (see updateToolbarPopup()). An // as closed, and from then on the toolbar popup opens it (see
// attempt already running when the window closes goes on and reports its // updateToolbarPopup()). An attempt already running when the window closes
// outcome to the page. // goes on and reports its outcome to the page.
// //
// This listener rejects a site-connection or network-switch approval whose // This listener rejects a site-connection or network-switch approval whose
// window closes before its popup connected its port. Once the port is // window closes before its popup connected its port. Once the port is
+7 -5
View File
@@ -18,10 +18,11 @@
// updateState(fn) read fresh, apply fn to that fresh record, write it // updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored // back — all holding the lock every write of the stored
// record takes, so no other write lands in between, by // record takes, so no other write lands in between, by
// the background or by any page (README, "Stored state // the background or by any page except a page in a
// and its version"). The read is one storage round trip // Firefox private window (README, "Stored state and its
// ahead of the write rather than a page lifetime ahead of // version"). The read is one storage round trip ahead of
// it (which is what made the popup's saveState() need a // the write rather than a page lifetime ahead of it
// (which is what made the popup's saveState() need a
// per-field merge against a baseline at all). // per-field merge against a baseline at all).
// //
// A handler that must both read and write therefore does its network work // A handler that must both read and write therefore does its network work
@@ -72,7 +73,8 @@ async function updateStateOnce(mutate) {
// src/shared/browserApi.js) is held from the read to the write: two // src/shared/browserApi.js) is held from the read to the write: two
// background turns, or a turn and a page's save, never interleave // background turns, or a turn and a page's save, never interleave
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits // (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
// while it is held (README, "Stored state and its version"). // while it is held, except a page in a Firefox private window, which Firefox
// gives locks of its own (README, "Stored state and its version").
// //
// `mutate` must also not call updateState() itself, directly or through // `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds, // anything it awaits: the inner turn waits for the lock the outer one holds,
+6 -17
View File
@@ -1144,19 +1144,13 @@ function init(_ctx) {
// The window closes only once the background has the rejection. A message // The window closes only once the background has the rejection. A message
// sent as the window closes can be lost, and a closed window does not // sent as the window closes can be lost, and a closed window does not
// reject the request. After Confirm, the background refuses a rejection // reject the request.
// while the transaction is being sent. The window then stays open and
// shows the background's message.
$("btn-reject-tx").addEventListener("click", async () => { $("btn-reject-tx").addEventListener("click", async () => {
const response = await sendMessage({ await sendMessage({
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
}).catch(() => null); }).catch(() => {});
if (response && response.error) {
showError("approve-tx-error", response.error);
return;
}
window.close(); window.close();
}); });
@@ -1279,18 +1273,13 @@ function init(_ctx) {
if (outcome.retryable) setSignButtonBusy(false); if (outcome.retryable) setSignButtonBusy(false);
}); });
// As for the transaction's Reject above. After Sign, the background // As for the transaction's Reject above.
// refuses a rejection while the signature is being checked.
$("btn-reject-sign").addEventListener("click", async () => { $("btn-reject-sign").addEventListener("click", async () => {
const response = await sendMessage({ await sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
}).catch(() => null); }).catch(() => {});
if (response && response.error) {
showError("approve-sign-error", response.error);
return;
}
window.close(); window.close();
}); });
} }
+2
View File
@@ -143,6 +143,8 @@ async function resetProfile() {
try { try {
// Under the lock every write of the stored record takes, so the erase // Under the lock every write of the stored record takes, so the erase
// cannot land between another write's read and its write and be undone. // cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask")); await withLock("autistmask", () => storageRemove("autistmask"));
} catch (e) { } catch (e) {
log.errorf("state recovery: reset failed:", e); log.errorf("state recovery: reset failed:", e);
+3 -1
View File
@@ -581,7 +581,9 @@ function reportSaveFailure(err) {
// Each save runs start to finish holding the lock every write of the stored // Each save runs start to finish holding the lock every write of the stored
// record takes (withLock() in src/shared/browserApi.js), so no write by // record takes (withLock() in src/shared/browserApi.js), so no write by
// another page or the background lands between its read and its write and is // another page or the background lands between its read and its write and is
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). // lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox
// private window is the exception: Firefox gives it locks of its own (README,
// "Stored state and its version").
// //
// The same lock orders this page's own saves. showView() calls saveState() on // The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be // every navigation without awaiting it, so two saves from one page can be
+26 -77
View File
@@ -1244,10 +1244,6 @@ 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;
@@ -1340,18 +1336,15 @@ 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, atMost }, in step // that sets the input side, as { token, amount }, in step order.
// order. After the loop each joins the Amount line or gets lines of // After the loop each joins the Amount line or gets lines of its own
// its own (see permitStands). A take with `atMost` set takes at most // (see permitStands).
// 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, or at most that when `atMost` is set. A // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
// V2 exact-in of ALREADY_PAID takes nothing. // nothing.
const addUserPaid = (token, amount, atMost = false) => { const addUserPaid = (token, amount) => {
if (amount !== ALREADY_PAID) { if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
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
@@ -1794,9 +1787,6 @@ 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.
@@ -1825,14 +1815,6 @@ 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.
@@ -1923,13 +1905,7 @@ 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( addUserPaid(p.currency, p.amount);
p.currency,
p.amount,
routerMayHoldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
} }
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
@@ -2040,8 +2016,7 @@ 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. A take of at most its amount (see // total unknown, with no figure.
// 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
@@ -2076,17 +2051,14 @@ 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, atMost, recipient }. A null token is a step this // { token, amount }. A null token is a step this decoder did not read
// decoder did not read (see spentUnread()), or a token it does not // (see spentUnread()), or a token it does not know.
// 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, in // The takes from the sender that `total` adds up, each a quantity, as
// the shape of `spentFromSender`. When there is no Amount line, each // { token, amount }. When there is no Amount line, each gets lines of
// gets lines of its own (see below). The amount of a step that set // its own (see below). The amount of a step that set the input side
// the input side and is paid from the router counts in `total` but is // and is paid from the router counts in `total` but is not among them.
// 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
@@ -2101,11 +2073,7 @@ 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({ inTotal.push({ token: inputToken, amount: inputAmount });
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
@@ -2113,11 +2081,7 @@ 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({ spentFromSender.push({ token: inputToken, amount: inputAmount });
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) {
@@ -2125,7 +2089,6 @@ 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"
) { ) {
@@ -2230,9 +2193,8 @@ 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. `atMost` says the amount is a maximum (see // compared with one.
// userPaid). const spentText = (amount, info) => {
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 };
} }
@@ -2245,7 +2207,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 || atMost) { if (hasV2OrV3ExactOut) {
// 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
@@ -2298,33 +2260,20 @@ 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. A take with no recipient goes to the swap step // that one line alone.
// 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), s.atMost) ? spentText(s.amount, tokenInfo(s.token, sources)).display
.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));
if (!present(s.recipient)) { details.push({
details.push({ label: "Moved to",
label: "Moved to", value: "The swap step that spends it",
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,
});
}
} }
} }
+7 -56
View File
@@ -176,17 +176,17 @@ test("a connection prompt in the toolbar popup leaves the browser window alone",
expect(env.pageErrors).toEqual([]); expect(env.pageErrors).toEqual([]);
}); });
// Each row holds a transaction or signature approval as the background // A closed transaction or signature window leaves its request pending
// describes it, plus the element ids of the screen that shows it. `refusal` is // (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
// the message the background refuses a Reject with. // window only once the background has the rejection. A message sent as the
const TX_AND_SIGN_APPROVALS = [ // window closes can be lost, and the request would stay pending although the
// user rejected it.
test.each([
{ {
kind: "transaction", kind: "transaction",
view: "approve-tx", view: "approve-tx",
reject: "btn-reject-tx", reject: "btn-reject-tx",
error: "approve-tx-error",
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
refusal: "This transaction is already being sent.",
approval: { approval: {
type: "tx", type: "tx",
approvedFrom: ADDRESS, approvedFrom: ADDRESS,
@@ -206,9 +206,7 @@ const TX_AND_SIGN_APPROVALS = [
kind: "signature", kind: "signature",
view: "approve-sign", view: "approve-sign",
reject: "btn-reject-sign", reject: "btn-reject-sign",
error: "approve-sign-error",
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
refusal: "This request is already being signed.",
approval: { approval: {
type: "sign", type: "sign",
approvedFrom: ADDRESS, approvedFrom: ADDRESS,
@@ -219,14 +217,7 @@ const TX_AND_SIGN_APPROVALS = [
}, },
}, },
}, },
]; ])(
// A closed transaction or signature window leaves its request pending
// (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
// window only once the background has the rejection. A message sent as the
// window closes can be lost, and the request would stay pending although the
// user rejected it.
test.each(TX_AND_SIGN_APPROVALS)(
"Reject on a $kind approval closes the window only once the background has answered", "Reject on a $kind approval closes the window only once the background has answered",
async ({ view, reject, type, approval }) => { async ({ view, reject, type, approval }) => {
let describeApproval = null; let describeApproval = null;
@@ -268,43 +259,3 @@ test.each(TX_AND_SIGN_APPROVALS)(
expect(env.pageErrors).toEqual([]); expect(env.pageErrors).toEqual([]);
}, },
); );
// After Confirm or Sign, the background refuses a Reject while the transaction
// is being sent or the signature checked. The request is not rejected then, so
// the window stays open and shows the background's message.
test.each(TX_AND_SIGN_APPROVALS)(
"Reject on a $kind approval that the background refuses shows the background's message and keeps the window open",
async ({ view, reject, error, type, refusal, approval }) => {
let describeApproval = null;
let answerReject = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
describeApproval = reply;
}
if (msg.type === type) answerReject = reply;
},
},
});
describeApproval({
...approval,
origin: "https://dapp.example",
isPhishingDomain: false,
});
await settle();
const close = jest.fn();
globalThis.window.close = close;
const clicked = env.click(reject);
await settle();
answerReject({ error: refusal, retryable: false });
await clicked;
expect(close).not.toHaveBeenCalled();
expect(env.visibleViews()).toEqual([view]);
expect(env.text(error)).toBe(refusal);
expect(env.pageErrors).toEqual([]);
},
);
-84
View File
@@ -1989,90 +1989,6 @@ describe("a closed approval window leaves the request pending", () => {
expect(toolbarPopup(bg)).toBe("src/popup/index.html"); 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 () => { test("an address switch still rejects a closed approval", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
-9
View File
@@ -165,15 +165,6 @@ describe("declared icons", () => {
}); });
}); });
// Firefox gives a page in a private window locks of its own, so that page's
// save could land between the background's read and its write (README,
// "Stored state and its version").
describe("private windows", () => {
test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed");
});
});
describe("shipped Content Security Policy", () => { describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the // MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs. // background service worker, which is where libsodium runs.
+3 -2
View File
@@ -6,8 +6,9 @@
// now holds a lock from its read to its write. // now holds a lock from its read to its write.
// //
// The popup and the background run here as the real modules over one shared // The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and its pages share // storage stub, and share the lock the way the background and a page outside
// the browser's (tests/support/navigatorLocks.js). // a Firefox private window share the browser's
// (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
+3 -2
View File
@@ -4,8 +4,9 @@
// the callback settles, whether it resolved or rejected. That is what the // the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes, // browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads // `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's // shares it, as the extension's background and its pages share the browser's,
// (README, "Stored state and its version"). // except a page in a Firefox private window (README, "Stored state and its
// version").
// //
// Run before each test file by `setupFiles` in package.json. // Run before each test file by `setupFiles` in package.json.
+3 -332
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 to `router`. // the user.
function decodeSteps(commands, inputs, router = ROUTER_ADDR) { function decodeSteps(commands, inputs) {
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, "0x1", {}, USER_ADDR); return uniswap.decode(data, ROUTER_ADDR, "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,335 +2424,6 @@ 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) =>