1 Commits
Author SHA1 Message Date
sneak 292ebe544e fix: a permit shows on its own lines unless its amount is the Amount figure (closes #370)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A PERMIT2_PERMIT read after a step had set the input side named Token
In, and its amount could stand as the Amount figure, so the approval
screen could show an allowance where the swap's own input belonged. A
permit before the swap steps whose spender was not the router, or that
the router may let fail, had no line naming its spender.

A permit after the input side now leaves Token In and Amount to the
swap. Every permit whose amount does not stand as the Amount figure
gets three lines of its own: Permit2 allowance, Allowance token and
Allowance spender. README Transaction Decoding and the TxApproval
element list match.

Model: opus-5-5
2026-10-20 20:19:08 +00:00
24 changed files with 351 additions and 2350 deletions
+102 -162
View File
@@ -461,15 +461,13 @@ address, the transaction assertions run against the raw signed transaction
captured at `eth_sendRawTransaction` rather than against anything the extension captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, the stored network unchanged and send no `chainChanged` until it is approved, a
popup open all along is required to show the `[TESTNET]` banner once a switch to network switch in Settings is required to send the page `chainChanged` with the
Sepolia is approved and to drop it once the switch back is, a network switch in new chain id, a prompt raised while another approval window has focus is
Settings is required to send the page `chainChanged` with the new chain id, a required to open a window of its own, and the password is required to be absent
prompt raised while another approval window has focus is required to open a from every message the approval window sends to the background — with the
window of its own, and the password is required to be absent from every message message that would carry it required to be present, so that check cannot pass by
the approval window sends to the background — with the message that would carry observing nothing. That last one is the standing floor under
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
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
@@ -605,10 +603,9 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script, rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows. The popup open all along is required to background page and approval windows. The `personal_sign` prompt is also
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop required to fit its window without scrolling sideways, with the extension's
it once the switch back is. The `personal_sign` prompt is also required to fit pages at 170% zoom.
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
@@ -845,7 +842,7 @@ src/
views/ — one JS module per screen (home, send, approval, etc.) views/ — one JS module per screen (home, send, approval, etc.)
shared/ — modules used by both popup and background shared/ — modules used by both popup and background
alarms.js — recurring background jobs (extension alarms API) alarms.js — recurring background jobs (extension alarms API)
balances.js — ETH and tracked token balances via RPC, others via Blockscout balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only) ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule holders.js — holder-count parsing and the low-holder rule
@@ -1132,15 +1129,13 @@ scale is not an approximation but a different number — 1,000 units of a
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen 6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen
whose only job is to state what is being authorized. Both amount paths of that whose only job is to state what is being authorized. Both amount paths of that
screen take this rule: the ERC-20 `transfer`/`approve` line screen take this rule: the ERC-20 `transfer`/`approve` line
(`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`, (`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received`
`Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of lines (`src/shared/uniswap.js`). The token permission warning on the signature
a move into the router (`src/shared/uniswap.js`). The token permission warning screen takes the same rule for its amounts. An unbounded allowance or permit
on the signature screen takes the same rule for its amounts. An unbounded needs no scale to describe and is still shown as `Unlimited`. A source's answer
allowance or permit needs no scale to describe and is still shown as counts only if it is a whole number from 0 to 80: `decimals()` returns a
`Unlimited`. A source's answer counts only if it is a whole number from 0 to 80: `uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a
`decimals()` returns a `uint8`, but `formatUnits()` cannot format more than 80 token that reports 81 to 255 is shown as one whose scale nothing knows.
decimal places, so a token that reports 81 to 255 is shown as one whose scale
nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -1173,8 +1168,8 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line **Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid and no allowance goes to a spender other than the router, only the user is paid, see Transaction Decoding), do not always read as a
see Transaction Decoding), do not always read as a number. They can read: number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: - A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above. truncated to four decimals, with the floor and the zero cases described above.
@@ -1243,11 +1238,7 @@ only when no earlier step set it. It finds each side by three rules:
is their sum; in any other case the step gives no `Amount`. The decoder reads is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount. no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency, except that the step gives none when a actions that buy the output currency.
swap action in it also spends the output currency, because the step then does
not pay out all that those actions buy. A step that buys at least 0.5 WETH
with USDT and swaps 0.3 WETH of it for USDC, with WETH as its output side,
gives no `Min. received`.
3. If any swap action in the step could not be read, only `SETTLE`, 3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no `SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the `Amount` or `Min. received`. A side they did not name is unknown, and the
@@ -1379,30 +1370,23 @@ The core hierarchy is **Wallets → Addresses**:
- The user can have multiple wallets, each with multiple addresses (HD) or a - The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key). single address (key).
Which tokens an address shows is decided on every refresh by `refreshBalances()` Which tokens an address shows is decided by `fetchTokenBalances()` in
in `src/shared/balances.js`. The balance of each token tracked on the active `src/shared/balances.js`, from the Blockscout `token-balances` response, so
network is read with `balanceOf` through the RPC endpoint, for every address. tokens do appear without the user adding them. An ERC-20 is shown when its
Its holder count and `decimals` are those of Blockscout's entry for it in the balance is nonzero and it is in the bundled known-token list, is tracked by the
same refresh, kept from before while Blockscout fails. A read that fails keeps user, or has 1,000 or more holders; a token claiming a symbol from the bundled
that token's previous entry, as a failed ETH balance keeps its figure, or takes list from any other contract address is always dropped, and so is any token
Blockscout's entry from the same refresh when there is none. Every other token claiming a symbol that belongs to the native asset and therefore has no
comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
tokens do appear without the user adding them, and they keep their previous as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
figures while Blockscout fails. A tracked token is shown when its balance is tokens with fewer than 1,000 holders" setting governs the transaction history
nonzero, and any other ERC-20 when its balance is nonzero and it is in the and the send-screen token selector, not this list. A token's holder count is
bundled known-token list or has 1,000 or more holders. A token claiming a symbol unknown when the explorer reports none, or reports anything other than a whole
from the bundled list from any other contract address is always dropped, tracked number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
or not, and so is any token claiming a symbol that belongs to the native asset in `src/shared/holders.js`). This list does not take an unknown count as 1,000
and therefore has no legitimate contract at all (`"ETH"`, and every network's or more, so such a token is shown only when it is on the bundled list or
`nativeCurrency`, such as `"SepoliaETH"`, on every network). That filter is tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs admits, however small, but a holding below 0.000001 is left out of the balance
the transaction history and the send-screen token selector, not this list. A
token's holder count is unknown when the explorer reports none, or reports
anything other than a whole number written in digits alone, such as `1,000` or
`1e3` (`parseHoldersCount()` in `src/shared/holders.js`). This list does not
take an unknown count as 1,000 or more, so such a token is shown only when it is
on the bundled list or tracked. Every nonzero holding of a token shown is stored
exactly, however small, but a holding below 0.000001 is left out of the balance
lists, the send-screen token selector, the address total and the remove-address lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked confirmation screens show it when its token is the one being sent. Tracked
@@ -1578,29 +1562,20 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed "Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it. while the user was away from it.
A network switch leaves every screen on the page showing the previous network. A network switch in Settings leaves every screen beneath Settings showing the
The user makes it in Settings, or approves a site's request and the background previous network, so after it "Back" behaves as in a reopened popup: every
saves it. Any other popup open meanwhile takes the switch from the stored screen on the stack counts as not yet rendered, and the stack is cut at the
record, even one saved while it was opening, and never switches the network first screen that cannot be rendered from saved state, such as Send or AddToken,
itself. A switch the user picks in its Settings while it takes one is made after or at AddressToken for any token but the native one, because that token was
it, not overwritten by it. An approval window does not take the switch. After picked on the previous network. The cut stack is saved with the switch, so a
the switch, whichever way the page got it, "Back" behaves as in a reopened popup closed straight after it and reopened onto Settings lands in the same
popup: every screen on the stack counts as not yet rendered, and the stack is place. "Back" then renders the screen it lands on for the new network, and
cut at the first screen that cannot be rendered from saved state, such as Send anything typed there and not saved, which belonged to the previous network, is
or AddToken, or at AddressToken for any token but the native one, because that dropped. ConfirmTx, WaitTx, SuccessTx, ErrorTx and TransactionDetail are
token was picked on the previous network and is no longer selected. The cut rendered instead for the network of the transaction they show: the network it
stack is saved with the popup's new network, so a popup closed straight after was reviewed on for ConfirmTx, and the network it was sent on for the others.
and reopened lands in the same place. The screen on top is drawn again for the Without a wallet the stack is not cut, because it starts at Welcome, which shows
new network: Settings in place, and any other screen as a reopened popup draws nothing from a network.
it, so Home takes the place of one that cannot be rendered from saved state or
of AddressToken for a token no longer selected. "Back" then renders the screen
it lands on for the new network, and anything typed there and not saved, which
belonged to the previous network, is dropped. ConfirmTx, WaitTx, SuccessTx,
ErrorTx and TransactionDetail are rendered instead for the network of the
transaction they show: the network it was reviewed on for ConfirmTx, and the
network it was sent on for the others. Without a wallet the stack is not cut and
only Settings is drawn again, because the stack starts at Welcome, and Welcome
and AddWallet show nothing from a network.
Every screen that holds secret material in the page registers a cleanup with Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every `onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
@@ -2117,11 +2092,8 @@ view would leave a wallet one click from deletion.
none, and the tokens tracked on that network, and sends `chainChanged` none, and the tokens tracked on that network, and sends `chainChanged`
with the new chain id to every open tab. A token is tracked only on the with the new chain id to every open tab. A token is tracked only on the
network it was added on, because a contract address names a token on one network it was added on, because a contract address names a token on one
network only. Switching also clears every address's balances. A balance network only. Choosing the network already active changes nothing and
refresh, by the popup or the background, stores what it fetched only if sends nothing
the stored network is still the one it fetched on when it returns, so the
previous network's balances never reach the new one. Choosing the network
already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2451,11 +2423,10 @@ view would leave a wallet one click from deletion.
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give, amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, what each what each `PERMIT2_TRANSFER_FROM` moves and where to, what each
`PERMIT2_PERMIT` whose amount is not the `Amount` figure, and each `PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender
`PERMIT2_PERMIT_BATCH`, lets its spender take, steps, deadline, and unless take, steps, deadline, and unless a swap pays only the From address, a
a swap pays only the From address, a `Paid to` line for each other address `Paid to` line for each other address it pays, or saying that a token
it pays, or saying that a token stays in the router or who it pays is stays in the router or who it pays is unknown (see Transaction Decoding)
unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -2669,8 +2640,7 @@ AutistMask is not a fully self-contained offline tool. It necessarily
communicates with three external services to function as a wallet: communicates with three external services to function as a wallet:
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query - **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), among balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
them the balance of every tracked token at every address (`balanceOf`),
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`), estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. The default endpoint is a public RPC (configurable by the user to receipts. The default endpoint is a public RPC (configurable by the user to
@@ -3209,9 +3179,8 @@ Currently supported:
`Min. paid out`, because the swap's minimum includes the fee. The minimum `Min. paid out`, because the swap's minimum includes the fee. The minimum
reads `Min. received` only when some step pays the transaction's sender (the reads `Min. received` only when some step pays the transaction's sender (the
From address on TxApproval, the sender the explorer reports on From address on TxApproval, the sender the explorer reports on
TransactionDetail), no `Paid to` line is shown, and no permit or batch grants TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
an allowance to a spender other than the router (see below). `Paid to` lines `Token Out` name each other address paid; one reads
after `Token Out` name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a `The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example, bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` `The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
@@ -3246,56 +3215,37 @@ Currently supported:
added to the total while both are quantities: a V3 exact-in of 600 USDT and a added to the total while both are quantities: a V3 exact-in of 600 USDT and a
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When
the decoder does not know a take's amount, the total is not known and there is the decoder does not know a take's amount, the total is not known and there is
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, no `Amount` line. `Token In` is the token of the last `PERMIT2_PERMIT` the
`Unlimited` included, stands as the `Amount` figure in place of the total, decoder reads before the step that sets the input side, when there is one. Its
which it does only in the plain shape of Uniswap's own routes: it comes before amount, `Unlimited` included, stands as the `Amount` figure in place of the
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the total only in the plain shape of Uniswap's own routes: it is the only
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, `PERMIT2_PERMIT` in the transaction, its spender is the router, the router may
the router may not let it fail, no step before it takes its token, a not let it fail, no step before it takes its token or a token the decoder does
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, not know, and the decoder reads every step after it in full. Permit2 then lets
and the decoder reads every step after it in full. Permit2 then lets the the router take no more of the token than that amount, so the permit covers
router take no more of the token than that amount, so the permit covers every every take of it. A step the decoder does not read in full may raise what
take of it. A step before the permit takes under whatever allowance the router Permit2 lets the router take, as a `PERMIT2_PERMIT_BATCH` can: a command the
already had, so it may take more than the permit's amount, which would then decoder does not read (listed at the end of this section), a step whose input
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of it cannot read, in whole or in part, and a step it fails on. In any other case
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 the line gives the total, as with no permit. When the step that sets the input
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A
later permit or batch may raise what Permit2 lets the router take, and so may
a step the decoder does not read in full: a command the decoder does not read
(listed at the end of this section), a step whose input it cannot read, in
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before
the step that sets the input side or after it, leaves `Token In` and the
`Amount` line as they read with no permit, unless it lets a spender other than
the router take `Token In` (see below): a permit of 5,000 DAI to another
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
to the router. When a permit's amount stands and the step that sets the input
side spends a token other than the permit's, or one the decoder does not know, side spends a token other than the permit's, or one the decoder does not know,
that step's amount gets lines of its own, unless it states no amount and the its amount gets lines of its own, unless it states no amount and the user pays
user pays it none of its input token. Each take the total does not count gives it none of its input token, and the total has no figure to start from: unless
the permit's amount stands there is no `Amount` line, and each take of the
permit's token gets lines of its own. Each take the total does not count gives
three lines after the `Amount` line (after `Token In` when there is none): one three lines after the `Amount` line (after `Token In` when there is none): one
of a token other than `Token In`; one of `Token In` after an amount that is of a token other than `Token In`; one of `Token In` after an amount that is
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
whose amount the decoder does not know. When there is no `Amount` line, each whose amount the decoder does not know. When the step that sets the input side
take the total counts gets them too, so that every amount the decoder reads is states no amount, the user pays it some of its input token, and there is no
shown: the amount the step that sets the input side states, when it is a `Amount` line, that step gets them too whenever another take's lines give a
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose below or, into the router, on its `Paid to` line, so that figure cannot read
ETH comes from the transaction's value), and each later take of `Token In` as all that leaves. When it states no amount and the user pays it none of its
added to it. A step that sets the input side and is paid from the router takes input token, as when the router pays it, that step takes none of that token
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder from the user's wallet and never gets them, with a permit or without. They
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give come in step order, the step that sets the input side first when it has them:
lines for both amounts. When the step that sets the input side states no
amount, the user pays it some of its input token, and there is no `Amount`
line, that step gets them too whenever another take's lines give a figure, or
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
the router, on its `Paid to` line, so that figure cannot read as all that
leaves. When it states no amount and the user pays it none of its input token,
as when the router pays it, that 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 `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
@@ -3319,34 +3269,24 @@ Currently supported:
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
`Token moved into the router` line already give the amount, the token and `Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose where it goes, and so its amount is shown once. A `PERMIT2_PERMIT` after the
amount does not stand as the `Amount` figure, before the step that sets the step that sets the input side names neither `Token In` nor the `Amount`
input side or after it, gives three lines, in step order, after those for each figure, which keep that step's token and the total: a V3 exact-in of 600 USDT
followed by a permit of 5,000 DAI reads `Swap USDT → USDC` with
`Amount 600.0000 USDT`. Every `PERMIT2_PERMIT` whose amount does not stand as
the `Amount` figure, before the step that sets the input side or after it,
gives three lines, in step order, after those for each
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, `5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose `Allowance spender`, the address it lets take the token. So does one whose
amount stands gives none: the `Amount` line gives its amount, `Token In` its spender is not the router, or that the router may let fail. Only a permit
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH` whose amount stands gives none: the `Amount` line gives its amount, `Token In`
grants, one for each token it names, all for its one spender, gives the same its token, and its spender is the router. These lines change neither the
three lines, in step order with those of each `PERMIT2_PERMIT`. A `Paid to` lines nor the minimum's label. A step goes unread when its recipient
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot does not decode as an address, when it is a V3 swap step whose path is shorter
read, such as one whose token or spender word has a nonzero top byte, may let than one token address or has bytes after its last hop, or when it is a
any spender take any amount of any token: it gives one `Permit2 allowance` command the decoder does not read: every command other than `PERMIT2_PERMIT`,
line alone, reading `Unknown (could not be decoded)`. These lines change no
`Paid to` line: a permit or batch pays nobody. But code that a step runs, such
as a V4 pool's hook or a token's own code, can call a spender other than the
router, which can then take up to its allowance from the user's wallet before
the transaction ends. So an allowance to any spender but the router, or a
permit or batch the decoder cannot read, makes the minimum read
`Min. paid out`, and when its token is `Token In`, or the decoder does not
know its token or `Token In`, it leaves no `Amount` line, whose figure might
be too low: a permit of 5,000 USDT to another address and then a V3 exact-in
of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its
own. A step goes unread when its recipient does not decode as an address, when
it is a V3 swap step whose path is shorter than one token address or has bytes
after its last hop, or when it is a command the decoder does not read: every
command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
-8
View File
@@ -1379,13 +1379,6 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW. // balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a // Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip. // wallet or address added, renamed or deleted during the round trip.
//
// Nothing is applied when the network stored NOW is not the one the refresh
// ran on. A network switch during the round trip cleared every address's
// balances (applyChainSwitchFields()), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network.
async function backgroundRefresh() { async function backgroundRefresh() {
const s = await getState(); const s = await getState();
const now = Date.now(); const now = Date.now();
@@ -1409,7 +1402,6 @@ async function backgroundRefresh() {
} }
await updateState((fresh) => { await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) { for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) { for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase()); const got = refreshed.get(String(addr.address).toLowerCase());
+3 -72
View File
@@ -5,14 +5,12 @@ const {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
} = require("../shared/state"); } = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log"); const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const { const {
$, $,
showView, showView,
@@ -24,7 +22,6 @@ const {
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const { const {
onStorageChanged,
tabsGetCurrent, tabsGetCurrent,
tabsGetZoom, tabsGetZoom,
windowsGetCurrent, windowsGetCurrent,
@@ -71,55 +68,23 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
let refreshTimer = null; let refreshTimer = null;
// The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// reaches this page only after the stored record (followNetworkSwitch() below).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
try { try {
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([ await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances( refreshBalances(
wallets, state.wallets,
state.rpcUrl, state.rpcUrl,
state.blockscoutUrl, state.blockscoutUrl,
state.trackedTokens, state.trackedTokens,
networkId, state.networkId,
pageClosed.signal, pageClosed.signal,
), ),
]); ]);
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
}
}
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
const got = refreshed.get(addr.address);
if (!got) continue;
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
return true; await saveState();
});
renderWalletList(); renderWalletList();
} catch (e) { } catch (e) {
// Every call site fires this and walks away — the boot below, the ten // Every call site fires this and walks away — the boot below, the ten
@@ -151,7 +116,6 @@ const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
startRefresh, startRefresh,
restoreView,
pageClosed: pageClosed.signal, pageClosed: pageClosed.signal,
showAddWalletView: () => { showAddWalletView: () => {
pushCurrentView(); pushCurrentView();
@@ -219,28 +183,6 @@ function fallbackView() {
showView("main"); showView("main");
} }
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
async function followNetworkSwitch() {
let switched = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId === state.networkId) return false;
settings.cutViewStack();
takeStoredNetwork(stored);
clearPopupCaches();
switched = true;
}).catch(() => {});
if (switched) settings.showNewNetwork();
}
// The background opens the approval window as wide as the page, but the // The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than // browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is // the window takes a vertical scrollbar out of it too. So once the screen is
@@ -385,17 +327,6 @@ async function init() {
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.init(ctx); deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the
// one this page's own switch in Settings saves names the network it
// already has, and a record naming none is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch();
});
// A switch saved since loadState() above, before there was a listener.
followNetworkSwitch();
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");
} else { } else {
+2 -2
View File
@@ -39,8 +39,8 @@ function markViewRendered(view) {
} }
// Begin a fresh page-load scope. The popup gets one by being loaded, and // Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one after a network switch, because every screen then on the page // asks for one when the user switches the network, because every screen
// shows the previous network (showNewNetwork() in views/settings.js); the // then on the page shows the previous network (views/settings.js); the
// unit tests, which simulate several page loads against one module // unit tests, which simulate several page loads against one module
// instance, ask for one too. // instance, ask for one too.
function resetRenderedViews() { function resetRenderedViews() {
+28 -61
View File
@@ -38,8 +38,6 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi"); const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
@@ -245,51 +243,7 @@ async function renderSiteLists() {
); );
} }
// A network switch, made by the user here or saved by the background once the function init(ctx) {
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. These two leave the page as README's
// Screen Map describes, the first before the switch is saved, so that save
// keeps the cut for a popup closed straight after, the second once `state`
// holds the new network.
//
// The stack is cut at the first screen that cannot be rendered from saved
// state, such as Send or AddToken, and at AddressToken unless it shows the
// native token: any other token there was picked on the previous network, so
// it is no longer selected either. Without a wallet the stack starts at
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
function cutViewStack() {
if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1) {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.selectedToken = null;
}
state.viewStack = restorableStack(state.viewStack, state.currentView);
}
// Every screen counts as not yet rendered, so "Back" renders the one it lands
// on for the new network. Settings on top is brought up to date in place;
// any other screen is drawn again as a reopened popup draws it, which is Home
// for one that cannot be rendered from saved state. Without a wallet any other
// screen is Welcome or AddWallet, which show nothing from a network.
function showNewNetwork() {
resetRenderedViews();
if (state.currentView === "settings") {
markViewRendered("settings");
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
} else if (state.hasWallet) {
ctx.restoreView();
}
updateDebugBanner(state.currentView);
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx); deleteWallet.init(ctx);
showPhrase.init(); showPhrase.init();
@@ -365,18 +319,37 @@ function init(_ctx) {
const networkSelect = $("settings-network"); const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value; const newId = networkSelect.value;
cutViewStack(); if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved // Open pages are told by the background, as after a site's approved
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
showNewNetwork(); resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
@@ -511,10 +484,4 @@ function init(_ctx) {
}); });
} }
module.exports = { module.exports = { init, show, renderSiteLists };
init,
show,
renderSiteLists,
cutViewStack,
showNewNetwork,
};
+12 -124
View File
@@ -1,5 +1,4 @@
// Balance fetching: ETH balances and the balances of the tokens the user // Balance fetching: ETH balances via RPC, ERC-20 token balances via
// tracks via RPC, the other ERC-20 token balances and the holder counts via
// Blockscout, ENS reverse lookup via RPC. // Blockscout, ENS reverse lookup via RPC.
const { const {
@@ -82,9 +81,7 @@ function rawUnits(value) {
// Fetch token balances for a single address from Blockscout. // Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }]. // Returns [{ address, name, symbol, decimals, balance, holders }].
// Filters out spam: only shows tokens that are in the known token list, // Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's // explicitly tracked by the user, or have >= 1000 holders.
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
// //
// `decimals` and `balance` are each null when the answer is unknown, the same // `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the // way `holders` already is. Absence is never filled in here: this is the
@@ -208,96 +205,8 @@ async function fetchTokenBalances(
} }
} }
// The entries at `address` of the tokens the user tracks, shaped as
// fetchTokenBalances() returns them, for the tokens the address holds.
//
// The balance is read with balanceOf through the RPC endpoint. The name and
// symbol are the tracked token's. `decimals` and `holders` are those of
// Blockscout's entry for the token in `explorerRequest`, this refresh's
// fetchTokenBalances() for the address; while Blockscout fails they are kept
// from the token's entry in `previous`, the address's entries before this
// refresh. The balance is stated at the scale the screens read the token at
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
// records, then Blockscout's, as in fetchTokenBalances().
//
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
// balance keeps its figure, or takes Blockscout's entry from this refresh when
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
//
// `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
listed.map(async (token) => {
try {
const contract = new Contract(
token.address,
ERC20_ABI,
provider,
);
return await contract.balanceOf(address);
} catch (e) {
if (!signal?.aborted) {
log.errorf(
"token balance failed",
address,
token.address,
e.shortMessage || e.message,
);
}
return null;
}
}),
);
const explorerRows = await explorerRequest;
const reported = explorerRows ?? previous;
const rows = [];
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
}
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
name: token.name || "",
symbol: token.symbol || "???",
decimals: decimals,
// null when nothing knows the scale: a holding whose amount
// cannot be stated, never a zero.
balance: scale === null ? null : formatTokenBalance(raw, scale),
holders: entry ? entry.holders : null,
});
});
return rows;
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as // `signal` is as for fetchTokenBalances().
// for fetchTokenBalances().
async function refreshBalances( async function refreshBalances(
wallets, wallets,
rpcUrl, rpcUrl,
@@ -308,8 +217,6 @@ async function refreshBalances(
) { ) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl)); log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = []; const updates = [];
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -359,42 +266,23 @@ async function refreshBalances(
}), }),
); );
// ERC-20 token balances: the tracked tokens' through the RPC // ERC-20 token balances via Blockscout
// endpoint, and from Blockscout the tokens the user has not updates.push(
// tracked. When Blockscout fails, the untracked rows from before fetchTokenBalances(
// this refresh stay. The Blockscout request is started first and
// handed to fetchTrackedTokenBalances() too, which waits for it
// after sending its balanceOf reads, so the two run side by side.
const previous = addr.tokenBalances || [];
const explorerRequest = fetchTokenBalances(
addr.address, addr.address,
blockscoutUrl, blockscoutUrl,
tracked, trackedTokens,
signal, signal,
); ).then((balances) => {
updates.push( if (balances !== null) {
Promise.all([ addr.tokenBalances = balances;
fetchTrackedTokenBalances(
provider,
addr.address,
tracked,
previous,
explorerRequest,
signal,
),
explorerRequest,
]).then(([trackedRows, explorerRows]) => {
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances = trackedRows.concat(untrackedRows);
log.debugf( log.debugf(
"Token balances", "Token balances",
addr.address, addr.address,
addr.tokenBalances.length, balances.length,
"tokens", "tokens",
); );
}
}), }),
); );
} }
-14
View File
@@ -224,19 +224,6 @@ function storageRemove(keys) {
return Promise.resolve(storage.remove(keys)); return Promise.resolve(storage.remove(keys));
} }
/**
* Call `listener` after every write to `storage.local`, whether this page,
* another page or the background made it, with what the write changed, as the
* browser reports it: `{ [key]: { oldValue, newValue } }`.
*
* @param {function(Object): void} listener
*/
function onStorageChanged(listener) {
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
if (areaName === "local") listener(changes);
});
}
/** /**
* Run `fn` while holding the browser's lock called `name` * Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension * (`navigator.locks`), and settle as `fn` settles. Every page of the extension
@@ -398,7 +385,6 @@ module.exports = {
getPlatformInfo, getPlatformInfo,
hasBrowserNamespace, hasBrowserNamespace,
notify, notify,
onStorageChanged,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
sessionStorageGet, sessionStorageGet,
+17 -37
View File
@@ -1,18 +1,8 @@
// The popup's network switch. // Consolidated chain-switch handler for the popup.
// //
// A network switch reaches the popup's `state` in one of two ways: // Every state change required when the active network changes is
// // performed here so that callers (settings UI, future chain additions) all go
// - onChainSwitch() below makes the switch the user picks in Settings, with // through a single code path.
// applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js).
//
// Both change `state` inside a save, holding the lock, and both reset the
// popup's own caches through clearPopupCaches(), so a reset added there
// reaches both. A field applyChainSwitchFields() sets has to be copied by
// copyChainSwitchFields() too.
// //
// Adding a new chain (e.g. ETC) requires only a new entry in // Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed. // networks.js — no per-caller wiring is needed.
@@ -30,32 +20,22 @@
const { applyChainSwitchFields } = require("./chainSwitchFields"); const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices"); const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state. // Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain, or null when // Returns the network configuration object for the new chain.
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followNetworkSwitch() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
let net = null;
await saveState(() => { const net = applyChainSwitchFields(state, newNetworkId);
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); // --- price cache ---
clearPopupCaches(); // Prices are chain-specific (testnet tokens are worthless,
}); // ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
return net; return net;
} }
module.exports = { onChainSwitch, clearPopupCaches }; module.exports = { onChainSwitch };
+2 -31
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of // the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js). // its own read-modify-write (src/background/state.js).
// //
// Everything here is synchronous and touches nothing but the objects it is // Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is // given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage // what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back. // microseconds earlier and is about to be written back.
@@ -76,33 +76,4 @@ function applyChainSwitchFields(s, newNetworkId) {
return net; return net;
} }
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it: module.exports = { applyChainSwitchFields };
// the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in
// src/shared/state.js). Balances are taken by address, and an address
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens;
s.trackedTokens = switched.trackedTokens;
s.networkId = switched.networkId;
s.rpcUrl = switched.rpcUrl;
s.blockscoutUrl = switched.blockscoutUrl;
s.lastBalanceRefresh = switched.lastBalanceRefresh;
s.tokenHolderCache = switched.tokenHolderCache;
s.fraudContracts = switched.fraudContracts;
const stored = new Map();
for (const wallet of switched.wallets) {
for (const addr of wallet.addresses) stored.set(addr.address, addr);
}
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
}
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
+12 -36
View File
@@ -23,7 +23,6 @@
// guarantee. // guarantee.
const { networkById } = require("./networks"); const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const { const {
DEFAULT_STATE, DEFAULT_STATE,
PERSISTED_FIELDS, PERSISTED_FIELDS,
@@ -126,10 +125,8 @@ function currentNetwork() {
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network // finished, or when its last successful saveState() began. saveState() diffs
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // the live state against this to find only the fields THIS page changed since.
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -469,10 +466,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// exists to remove, just delayed and confined to one page instead of two // exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it // (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. The // before this fix; only the persisted record is guaranteed current.
// network is the exception: the popup takes a switch another context saved async function saveStateOnce() {
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js). // A copy, so what this save compares and writes is the page's state as it
async function saveStateOnce(beforeWrite) { // stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -486,12 +485,6 @@ async function saveStateOnce(beforeWrite) {
// come from storage in its loaded (self-healed) shape. See // come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js. // normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask); const fresh = normalizePersisted(result.autistmask);
if (beforeWrite && beforeWrite(fresh) === false) return;
// A copy, so what this save compares and writes is the page's state as it
// stood once the stored record was read. A change made while it waits on
// the write is left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const merged = { ...fresh }; const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) { for (const key of PERSISTED_FIELDS) {
@@ -586,15 +579,10 @@ function reportSaveFailure(err) {
// 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
// asked for at once; they take the lock in the order they were asked, and each // asked for at once; they take the lock in the order they were asked, and each
// compares the page's state, as it stands once the stored record has been read // compares the page's state as it stands when its turn begins against the
// and `beforeWrite` has run, against the baseline the save before it left. // baseline the save before it left.
// function saveState() {
// `beforeWrite`, when given, is called inside the lock with the stored record const turn = withLock("autistmask", saveStateOnce);
// as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is
// written; any other return lets the save go ahead.
function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this // Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await // one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling. // keeps its own error handling.
@@ -602,17 +590,6 @@ function saveState(beforeWrite) {
return turn; return turn;
} }
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followNetworkSwitch() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -658,7 +635,6 @@ module.exports = {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+64 -175
View File
@@ -336,8 +336,7 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// currencies, and those commands may name one, so saying the calldata did not // currencies, and those commands may name one, so saying the calldata did not
// name it would be false. The Paid to line uses it too, when a step may pay an // name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read, and so do the lines for what a step // address this decoder did not read, and so do the lines for what a step
// takes from the sender, when this decoder does not know it, and for what a // takes from the sender, when this decoder does not know it.
// permit it could not read allows.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or // The Paid to line for each token paid to the router, the swap's output or
@@ -529,32 +528,6 @@ function decodePermit2(input) {
} }
} }
// Decode PERMIT2_PERMIT_BATCH (command 0x03) input bytes into the allowances
// it grants, one for each token it names, all for its one spender, in the
// shape decodePermit2() returns. Each release's
// `contracts/base/Dispatcher.sol`, at the commit linked for it above, hands
// Permit2 the batch in this shape.
// ABI: (((address token, uint160 amount, uint48 expiration, uint48 nonce)[],
// address spender, uint256 sigDeadline), bytes signature)
function decodePermit2Batch(input) {
try {
const d = coder.decode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
input,
);
return d[0][0].map((details) => ({
token: details[0],
amount: details[1],
spender: d[0][1],
}));
} catch {
return null;
}
}
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the // Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the
// check when `owner` holds at least `minBalance` of `token`, and reads `owner` // check when `owner` holds at least `minBalance` of `token`, and reads `owner`
// as written, without map(), on every release that has the command: // as written, without map(), on every release that has the command:
@@ -624,14 +597,12 @@ function decodeV2SwapExactOut(input) {
// token(20) ...), or null when it is not one token followed by whole hops of // token(20) ...), or null when it is not one token followed by whole hops of
// a fee and a token (40 hex digits, then 46 per hop). The router reads only // a fee and a token (40 hex digits, then 46 per hop). The router reads only
// whole hops and ignores any bytes after the last one, so the last 20 bytes // whole hops and ignores any bytes after the last one, so the last 20 bytes
// of such a path are not the token it reads there. Both are checksummed, as // of such a path are not the token it reads there.
// the ABI decoder gives every other address, so a token reads the same on
// every line.
function decodeV3Path(pathHex) { function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null; if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = getAddress("0x" + hex.slice(0, 40)); const first = "0x" + hex.slice(0, 40);
const last = getAddress("0x" + hex.slice(-40)); const last = "0x" + hex.slice(-40);
return { first, last }; return { first, last };
} }
@@ -953,9 +924,7 @@ function totalAmount(amounts) {
// 2. Amount is the amountIn of the swap action that spends Token In. If // 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is // several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same // their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out, except that there // for amountOutMin and the actions that buy Token Out.
// is none when a swap action in the step also spends Token Out: the step
// then does not pay out all that those actions buy.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL, // 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min. // TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received. // received.
@@ -1189,14 +1158,12 @@ function decodeV4Swap(input) {
const boughtOnly = currenciesNotIn(bought, spent); const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) { if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0]; tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
if (!spent.includes(tokenOut)) {
amountOutMin = totalAmount( amountOutMin = totalAmount(
swaps swaps
.filter((s) => s.tokenOut === tokenOut) .filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin), .map((s) => s.amountOutMin),
); );
} }
}
return { return {
tokenIn, tokenIn,
@@ -1238,21 +1205,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null; let inputToken = null;
let inputAmount = null; let inputAmount = null;
let inputEstablished = false; let inputEstablished = false;
// Whether the user pays the step that set the input side: a V2 or V3 // Whether the user pays the V4 step that set the input side any of
// swap step with `payerIsUser` true, a V4 step the user pays some of // its input currency (see decodeV4Swap()). Only a V4 step can set the
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH // input side with no amount, the one case in which this is read.
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// 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;
// Every allowance a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH grants, // Every PERMIT2_PERMIT this decoder read, before a step set the input
// before a step set the input side or after, in step order, as // side or after, in step order, as { token, amount, spender, step }.
// { token, amount, spender, step }. A permit or batch whose input // Each whose amount is not the Amount figure gets lines of its own
// this decoder cannot read is one with a null token, amount and // (see after the PERMIT2_TRANSFER_FROM lines).
// spender. Each but a permit whose amount is the Amount figure gets
// lines of its own (see after the PERMIT2_TRANSFER_FROM lines).
const permits = []; const permits = [];
// The index of each step this decoder did not read in full: a // The index of each step this decoder did not read in full: a
// command it does not read, a step whose input it cannot read, in // command it does not read, a step whose input it cannot read, in
@@ -1311,8 +1274,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// when no earlier hop set it. A half the establishing hop did not // when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in // state stays null and the line says so, rather than being filled in
// from a different hop. A PERMIT2_PERMIT sets no side here: after the // from a different hop. A PERMIT2_PERMIT sets no side here: after the
// loop, one whose amount stands as the Amount figure names Token In, // loop, the last one read before a step set the input side names
// and the Amount line is formed there, in one place (see // Token In, and the Amount line is formed there, in one place (see
// permitStands). // permitStands).
const setInput = (token, amount) => { const setInput = (token, amount) => {
inputToken = present(token) ? token : null; inputToken = present(token) ? token : null;
@@ -1357,15 +1320,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const spentUnread = () => { const spentUnread = () => {
userPaid.push({ token: null, amount: null }); userPaid.push({ token: null, amount: null });
}; };
// A PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH at `step` whose input this
// decoder cannot read may let any spender take any amount of any
// token: one line says so, there is no Amount line, whose figure may
// then be too low, and the minimum reads Min. paid out (see
// toAnotherSpender).
const permitUnread = (step) => {
permits.push({ token: null, amount: null, spender: null, step });
stepsUnread.push(step);
};
// Who the steps pay, in whichever step and of whatever token. // Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by // MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
@@ -1521,26 +1475,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permits.push({ ...p, step: i }); permits.push({ ...p, step: i });
keepsRouterWeth = true; keepsRouterWeth = true;
} else { } else {
permitUnread(i); stepsUnread.push(i);
} }
// Only a permit whose amount stands as the Amount figure // After a step set the input side, a permit names
// names Token In (see permitStands). Every other permit, // neither Token In nor the Amount figure: the swap's own
// before the step that sets the input side or after it, // input token and amount stay on those lines.
// leaves Token In and Amount as they read with no permit
// and gets lines of its own. One after a step set the input
// side never stands, so only one before it is kept here.
if (p && !inputEstablished) { if (p && !inputEstablished) {
// Whether a step before it took its token, or may // Whether a step before it took its token, or may
// have: one in `userPaid`, `movedIntoRouter` or // have: one in `userPaid`, when its token is the
// `movedFromSender`, when its token is the permit's // permit's or not known. No step has set the input
// or not known. No step has set the input side yet. // side yet.
const mayBeItsToken = (token) => const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token); !present(token) || sameAddress(token, p.token);
const tookBefore = [ const tookBefore = userPaid.some((t) =>
...userPaid, mayBeItsToken(t.token),
...movedIntoRouter, );
...movedFromSender,
].some((t) => mayBeItsToken(t.token));
permit = { permit = {
...p, ...p,
step: i, step: i,
@@ -1548,15 +1497,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
beforeEveryTake: !tookBefore, beforeEveryTake: !tookBefore,
}; };
} }
} else if (command === "PERMIT2_PERMIT_BATCH") {
// Each allowance it grants gets the lines of a permit
// whose amount does not stand. It pays nobody.
const batch = decodePermit2Batch(inputs[i]);
if (batch) {
permits.push(...batch.map((p) => ({ ...p, step: i })));
} else {
permitUnread(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") { } else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]); const p = decodePermit2TransferFrom(inputs[i]);
if (!p) { if (!p) {
@@ -1629,7 +1569,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) { if (s) {
if (!inputEstablished) { if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn); addUserPaid(s.tokenIn, s.amountIn);
} }
@@ -1653,7 +1592,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) { if (s) {
if (!inputEstablished) { if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn); addUserPaid(s.tokenIn, s.amountIn);
} }
@@ -1685,7 +1623,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) { if (s) {
if (!inputEstablished) { if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax); setInputOnce(s.tokenIn, s.amountInMax);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax); addUserPaid(s.tokenIn, s.amountInMax);
} }
@@ -1781,13 +1718,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "WRAP_ETH") { } else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
if (!inputEstablished) {
setInputOnce( setInputOnce(
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000",
w.amount, w.amount,
); );
inputPaidByUser = true;
}
addPayee(w.recipient); addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH. // CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) { if (w.amount === CONTRACT_BALANCE) {
@@ -1971,8 +1905,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
// Any other step may pay an address this decoder does not // Any other step may pay an address this decoder does not
// read: the NFT commands among them, and a type the // read: PERMIT2_PERMIT_BATCH and the NFT commands among
// release names no command for. // them, and a type the release names no command for.
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i); stepsUnread.push(i);
} }
@@ -2018,26 +1952,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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 PERMIT2_PERMIT names Token In only when its amount stands as the // The last PERMIT2_PERMIT read before a step set the input side names
// Amount figure in place of the total, which it does only in the // Token In. Its amount stands as the Amount figure in place of the
// plain shape of Uniswap's own routes: it comes before the step that // total only in the plain shape of Uniswap's own routes: it is the
// set the input side, it is the only PERMIT2_PERMIT in the // only PERMIT2_PERMIT in the transaction, its spender is the router,
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is // the router may not let it fail, no step before it took its token or
// the router, the router may not let it fail, no step before it took // one not known, and this decoder read every step after it in full
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token // (see stepsUnread). Permit2 then lets the router take no more of the
// included), and this decoder read every step after it in full (see // token than that amount, so the permit covers every take of it. A
// stepsUnread). Permit2 then lets the router take no more of the token // later step this decoder did not read may raise what Permit2 lets
// than that amount, so the permit covers every take of it. A step // the router take, as a PERMIT2_PERMIT_BATCH can. A step that may
// before it takes under whatever allowance the router already had, so // take from the sender what this decoder did not read leaves no
// it may take more. A later permit or batch, or a later step this // figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// decoder did not read, may raise what Permit2 lets the router take. // step leaves both sides unknown (below). A PERMIT2_PERMIT after a
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as // step set the input side counts among the PERMIT2_PERMITs here.
// they read with no permit, and gets lines of its own below. A step // Every PERMIT2_PERMIT but one whose amount stands gets lines of its
// that may take from the sender what this decoder did not read leaves // own below.
// no figure, permit or not (see spentUnread()), and so do a permit
// or batch whose input it cannot read (see permitUnread()) and an
// allowance of Token In to another spender (see toAnotherSpender). An
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -2045,7 +1975,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const permitStands = const permitStands =
permit !== null && permit !== null &&
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) && sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail && !permit.allowedToFail &&
permit.beforeEveryTake && permit.beforeEveryTake &&
@@ -2055,11 +1984,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// (see spentUnread()), or a token it does not know. // (see spentUnread()), or a token it does not know.
const spentFromSender = []; const spentFromSender = [];
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of a step that set the input side
// and is paid from the router counts in `total` but is not among them.
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
// whoever pays it. Stating none, it counts only when the user pays it // whoever pays it. Stating none, it counts only when the user pays it
@@ -2070,20 +1994,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The take of the step that set the input side, when the total // The take of the step that set the input side, when the total
// starts from it and it stated no amount (see below), or null. // starts from it and it stated no amount (see below), or null.
let firstTakeUnstated = null; let firstTakeUnstated = null;
if (!permitStands || sameAddress(inputToken, permit.token)) { if (permit === null || sameAddress(inputToken, permit.token)) {
total = inputAmount; total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
} }
} 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 the total starts with no figure.
spentFromSender.push({ token: inputToken, amount: inputAmount }); spentFromSender.push({ token: inputToken, amount: inputAmount });
} }
if (permitStands) inputToken = permit.token; if (permit !== null) inputToken = permit.token;
for (const t of userPaid) { for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken); const ofTokenIn = sameAddress(t.token, inputToken);
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
@@ -2093,7 +2014,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof t.amount === "bigint" typeof t.amount === "bigint"
) { ) {
total += t.amount; total += t.amount;
inTotal.push(t);
continue; continue;
} }
spentFromSender.push(t); spentFromSender.push(t);
@@ -2117,9 +2037,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step. What the other // or pay the user, before or after any other step, and a permit among
// steps state is then not the whole swap, so both sides are set // them would name Token In. What the other steps state is
// unknown, with no figure, wherever the step stands. // then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
if (hasSubPlan) { if (hasSubPlan) {
inputToken = null; inputToken = null;
inputAmount = null; inputAmount = null;
@@ -2222,30 +2143,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
return amountText(amount, info); return amountText(amount, info);
}; };
// Each allowance to a spender other than the router. Code that a step
// runs, such as a V4 pool's hook or a token's own code, can call that
// spender, which can then take up to the allowance from the sender
// before the transaction ends. A permit or batch this decoder could
// not read may be to any spender, of any token.
const toAnotherSpender = permits.filter(
(p) => !sameAddress(p.spender, toAddress),
);
// A null token in `spentFromSender`, a step this decoder did not read // A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and an allowance to another spender // or a token it does not know, leaves no Amount figure, which may
// that may be of Token In, leave no Amount figure, which may then be // then be too low.
// too low. With no Amount line, each take the total adds up gets
// lines of its own, first, so that every amount this decoder read is
// shown.
if ( if (
present(inputAmount) && present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) && spentFromSender.every((s) => present(s.token))
toAnotherSpender.every(
(p) =>
present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken),
)
) { ) {
const amount = spentText(inputAmount, inInfo); const amount = spentText(inputAmount, inInfo);
details.push({ details.push({
@@ -2253,8 +2156,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
value: amount.display, value: amount.display,
rawValue: amount.raw, rawValue: amount.raw,
}); });
} else {
spentFromSender.unshift(...inTotal);
} }
// 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
@@ -2297,24 +2198,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
); );
} }
// What each allowance of a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH lets // What each PERMIT2_PERMIT lets its spender take: its amount, its
// its spender take: its amount, its token and the spender, an address // token and the spender, an address on a line of its own. Permit2's
// on a line of its own. Permit2's maximum is unbounded. Only a permit // maximum is unbounded. Only a permit whose amount stands as the
// whose amount stands as the Amount figure (see permitStands) gets // Amount figure (see permitStands) gets none: that line already gives
// none: that line already gives its amount and Token In its token, // its amount and Token In its token, and its spender is the router.
// and its spender is the router. Every other allowance, one after the // Every other permit, one after the step that set the input side
// step that set the input side included, gets these lines, so nothing // included, gets these lines, so nothing it lets anyone take goes
// it lets anyone take goes unstated. A permit or batch this decoder // unstated.
// could not read gets one line saying so.
for (const p of permits) { for (const p of permits) {
if (permitStands && p.step === permit.step) continue; if (permitStands && p.step === permit.step) continue;
if (!present(p.token)) {
details.push({
label: "Permit2 allowance",
value: UNDECODED_CURRENCY,
});
continue;
}
details.push({ details.push({
label: "Permit2 allowance", label: "Permit2 allowance",
value: value:
@@ -2366,10 +2259,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// step pays the bridge, when one moves the sender's tokens into the // step pays the bridge, when one moves the sender's tokens into the
// router, when a token stays in the router, and when a payout could // router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some // not be read. The output is received by the sender only when some
// step pays the sender, there is no such line, and no allowance goes // step pays the sender and there is no such line. When no step names
// to another spender (see toAnotherSpender). When no step names anyone // anyone paid, one line says so. `paidTo` holds these lines and the
// paid, one line says so. `paidTo` holds these lines and the token // token line after each line about a token in the router.
// line after each line about a token in the router.
const outputHeld = const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({ const paidTo = paidElsewhere.map((address) => ({
@@ -2416,10 +2308,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (payeeUnread) { if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} }
const received = const received = paidToSender && paidTo.length === 0;
paidToSender &&
paidTo.length === 0 &&
toAnotherSpender.length === 0;
if (!paidToSender && paidTo.length === 0) { if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY }); paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
} }
+1 -49
View File
@@ -19,10 +19,7 @@
// that followed persisted the pre-refresh values — while still stamping // that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo. // lastBalanceRefresh, suppressing the redo.
// //
// A network switch landing inside a balance refresh's round trip is covered at // Both use the real persistence path over a cloning storage stub. Nothing here
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
//
// All use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it // asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right. // holds against any implementation that gets the outcome right.
@@ -398,48 +395,3 @@ describe("a balance refresh under another handler's state read", () => {
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE); expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
}); });
}); });
describe("a balance refresh under a network switch", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
// address's balances, and a refresh that started on the previous network
// must not write that network's back over them when it returns. The same
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// The user switches to Ethereum Mainnet in Settings while the round
// trip is out.
const during = bg.persisted();
applyChainSwitchFields(during, MAINNET.id);
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
expect(ranOn).toEqual(["sepolia", "mainnet"]);
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});
+4 -10
View File
@@ -18,11 +18,6 @@ const {
const RPC_URL = "https://rpc.example.invalid"; const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2"; const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const ADDRESS = "0x1111111111111111111111111111111111111111"; const ADDRESS = "0x1111111111111111111111111111111111111111";
const TRACKED = {
address: "0x7777777777777777777777777777777777777777",
symbol: "TRK",
decimals: 6,
};
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
let logged; let logged;
@@ -35,9 +30,9 @@ beforeEach(() => {
const failedToFetch = async () => { const failedToFetch = async () => {
throw new TypeError("Failed to fetch"); throw new TypeError("Failed to fetch");
}; };
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and // The RPC calls (ETH balance, ENS name, and the scan's balance and
// the scan's balance and transaction count) and the explorer request (the // transaction count) and the explorer request (token balances) all fail
// other token balances) all fail the same way. // the same way.
FetchRequest.registerGetUrl(failedToFetch); FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch); globalThis.fetch = jest.fn(failedToFetch);
}); });
@@ -54,7 +49,7 @@ function refresh(signal) {
wallets, wallets,
RPC_URL, RPC_URL,
EXPLORER_URL, EXPLORER_URL,
[TRACKED], [],
"mainnet", "mainnet",
signal, signal,
); );
@@ -65,7 +60,6 @@ test("a failure while the popup is open is reported", async () => {
for (const label of [ for (const label of [
"ETH balance failed", "ETH balance failed",
"ENS reverse failed", "ENS reverse failed",
"token balance failed",
"fetchTokenBalances failed: Failed to fetch", "fetchTokenBalances failed: Failed to fetch",
]) { ]) {
expect(logged.some((line) => line.includes(label))).toBe(true); expect(logged.some((line) => line.includes(label))).toBe(true);
-161
View File
@@ -1,161 +0,0 @@
// A balance refresh the popup started on one network, and that returns after
// the network was switched to another, writes nothing: the switch cleared every
// address's balances, and the previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
// by the user in this popup's Settings, or by the background after the user
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
afterEach(() => {
cleanupPopup();
});
// Starts the ten-second refresh and holds it open. The balance stand-in writes
// Ethereum Mainnet's balances in place, on the records handed in, as the real
// refreshBalances() does, once the returned function is called. Resolves with
// that function and the refresh still running.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
});
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
// recorded as done.
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The switch is made only once the refresh's save, holding the lock, has
// written Ethereum Mainnet's balances, and the switch then clears them.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
// The next read of the stored record, the refresh's save's, is held.
const get = page.storage.local.get;
const read = get.getMockImplementation();
let releaseRead;
get.mockImplementationOnce(async (key) => {
await new Promise((resolve) => {
releaseRead = resolve;
});
return read(key);
});
const finishing = refresh.finish();
await page.settle();
// The user picks Sepolia meanwhile; the switch's own save waits for the
// refresh's save to let the lock go.
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
releaseRead();
await finishing;
await switching;
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
test("a refresh that returns after the background switched the network writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { saveState } = require("../src/shared/state");
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told of it
// yet.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
await refresh.finish();
expectNothingStored(page);
// Nor does a later save by this page, such as every navigation makes.
await saveState();
expectNothingStored(page);
});
+1 -4
View File
@@ -446,10 +446,7 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => { global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP); bg.send(msg, POPUP);
}; };
const { state, loadState } = require("../src/shared/state"); await require("../src/shared/state").loadState();
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({ require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal, pageClosed: new AbortController().signal,
}); });
-12
View File
@@ -867,14 +867,6 @@ step(
"the page was not told of the approved switch: " + "the page was not told of the approved switch: " +
JSON.stringify(events), JSON.stringify(events),
); );
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await d.switchToWindow(env.popupWindow);
await d.waitFor(
"the open popup to show the testnet banner",
`const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";`,
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet); await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network"); await d.click("#btn-approve-network");
@@ -889,10 +881,6 @@ step(
isDeepStrictEqual(await storedNetwork(env), before), isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints", "switching back did not restore the mainnet network and endpoints",
); );
await d.waitFor(
"the open popup to drop the testnet banner",
`return document.getElementById("debug-banner") === null;`,
);
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
}, },
); );
-27
View File
@@ -3480,24 +3480,6 @@ function hostileSymbolState(page, tokenAddress) {
} }
test("a token whose symbol() returns markup renders as text (#307)", async (env) => { test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
// The stub token is no longer tracked from here on, so the explorer is
// what lists it, as it lists a token airdropped to the victim. A tracked
// token is listed from the RPC endpoint under the symbol it was added
// with, which the hostile symbol below never reaches
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
await env.page.click("#settings-tracked-tokens .btn-remove-token");
// The list is drawn again once the removal is saved.
await env.page.waitForFunction(
() =>
document
.getElementById("settings-tracked-tokens")
.innerText.trim() === "None",
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI); env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true; env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL; env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
@@ -4883,12 +4865,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " + "the page was not told of the approved switch: " +
JSON.stringify(events), JSON.stringify(events),
); );
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await env.page.waitForFunction(() => {
const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";
});
const restored = await openNetworkPrompt( const restored = await openNetworkPrompt(
env, env,
@@ -4910,9 +4886,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before), isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints", "switching back did not restore the mainnet network and endpoints",
); );
await env.page.waitForFunction(
() => document.getElementById("debug-banner") === null,
);
}); });
// Switching the network in Settings tells the page too, as an approved site // Switching the network in Settings tells the page too, as an approved site
-300
View File
@@ -1,300 +0,0 @@
// An open popup follows a network switch the background saves once the user
// approves a site's request: it takes the new network from the stored record
// and draws the screen it is on for it, as a switch in Settings does
// (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real
// popup over a stored profile on Ethereum Mainnet. The background's write is
// the storage stub's set, which the stub reports to the page as the browser
// does.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const { NETWORKS } = require("../src/shared/networks");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
afterEach(() => {
cleanupPopup();
});
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail,
// with DAI tracked there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() {
return bootPopup(profileOnAddress());
}
// The background saves a switch to `networkId`, as its updateState() does
// once the user approves a site's request.
async function backgroundSwitch(page, networkId) {
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, networkId);
await page.storage.local.set({ autistmask: record });
await page.settle();
await page.settle();
}
test("the popup open on AddressDetail shows the new network and its balances", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("DAI");
expect(page.node("debug-banner")).toBeNull();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).toContain("SepoliaETH");
expect(balances).not.toContain("DAI");
expect(page.text("debug-banner")).toBe("[TESTNET]");
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
});
test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["settings"]);
expect(page.value("settings-network")).toBe("sepolia");
expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST");
expect(page.text("debug-banner")).toBe("[TESTNET]");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).not.toContain("DAI");
});
// Neither can be drawn again for the new network: Send cannot be rendered from
// saved state, and DAI was picked on Ethereum Mainnet, where alone it is
// tracked. A reopened popup goes to Home in their place.
test.each([
["Send", (page) => page.click("btn-send")],
[
"AddressToken for DAI",
async (page) => {
const { state } = require("../src/shared/state");
state.selectedToken = MAINNET_TOKEN.address;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
},
],
])("the popup open on %s goes to Home", async (_view, open) => {
const page = await bootOnAddress();
await open(page);
expect(page.visibleViews()).not.toEqual(["address"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
});
// The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
const writes = set.mock.calls.length;
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.settle();
expect(get).toHaveBeenCalledTimes(reads + 1);
expect(set).toHaveBeenCalledTimes(writes + 1);
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
});
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("mainnet");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
test("a record that names no network is not followed", async () => {
const page = await bootOnAddress();
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
await set({
autistmask: {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [{ type: "hd", name: "Main", addresses: [] }],
},
});
await page.settle();
expect(get).toHaveBeenCalledTimes(reads);
expect(page.visibleViews()).toEqual(["address"]);
});
// A record that takes the switch from the switched record ends up as that
// record, balances the background fetched since included.
test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => {
const before = unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
networkEndpoints: {
sepolia: {
rpcUrl: "http://127.0.0.1:8545",
blockscoutUrl: "http://127.0.0.1:4000/api/v2",
},
},
lastBalanceRefresh: 1,
tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 },
fraudContracts: ["0x1111111111111111111111111111111111111111"],
});
const switched = structuredClone(before);
applyChainSwitchFields(switched, "sepolia");
switched.wallets[0].addresses[0].balance = "2.0";
const follower = structuredClone(before);
copyChainSwitchFields(follower, switched);
expect(follower).toEqual(switched);
});
+1 -1
View File
@@ -311,7 +311,7 @@ async function bootPopup(stored, options) {
const windowListeners = {}; const windowListeners = {};
globalThis.chrome = { globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged }, storage: { local: storage.local },
runtime: { runtime: {
sendMessage: jest.fn(async () => ({})), sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p, getURL: (p) => "chrome-extension://autistmask/" + p,
+5 -30
View File
@@ -18,9 +18,7 @@
// a storage stub by hand. // a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways. // One storage area: get, set and remove over its own store, cloning both ways.
// `changed` is handed what each set or remove changed, in the shape function makeArea(store, tick) {
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
const get = jest.fn(async (key) => { const get = jest.fn(async (key) => {
tick(); tick();
if (key === undefined || key === null) return structuredClone(store); if (key === undefined || key === null) return structuredClone(store);
@@ -36,25 +34,14 @@ function makeArea(store, tick, changed) {
const set = jest.fn(async (items) => { const set = jest.fn(async (items) => {
tick(); tick();
const changes = {};
for (const k of Object.keys(items)) { for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]); store[k] = structuredClone(items[k]);
} }
changed(changes);
}); });
const remove = jest.fn(async (key) => { const remove = jest.fn(async (key) => {
tick(); tick();
const changes = {}; for (const k of Array.isArray(key) ? key : [key]) delete store[k];
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
}); });
return { get, set, remove }; return { get, set, remove };
@@ -67,22 +54,12 @@ function makeArea(store, tick, changed) {
function makeStorageStub(initial, onOp) { function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {}; const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {}); const tick = onOp || (() => {});
// storage.onChanged: after a set or remove has finished, every listener is const local = makeArea(store, tick);
// called with what it changed and the area's name, as the browser calls
// them for a write by any page or the background.
const listeners = [];
const announce = (areaName) => (changes) => {
Promise.resolve().then(() => {
for (const fn of listeners) fn(structuredClone(changes), areaName);
});
};
const local = makeArea(store, tick, announce("local"));
return { return {
// Drop this straight in as chrome.storage. // Drop this straight in as chrome.storage.
local, local,
session: makeArea({}, tick, announce("session")), session: makeArea({}, tick),
onChanged: { addListener: (fn) => listeners.push(fn) },
get: local.get, get: local.get,
set: local.set, set: local.set,
remove: local.remove, remove: local.remove,
@@ -94,9 +71,7 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store) ? structuredClone(store)
: structuredClone(store[key]), : structuredClone(store[key]),
// Seed or replace a record without going through the module under // Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this". Nothing is // test — for standing in as "another page wrote this".
// announced to storage.onChanged, so it also stands in for the time
// before the browser tells this page of such a write.
write: (key, value) => { write: (key, value) => {
store[key] = structuredClone(value); store[key] = structuredClone(value);
}, },
-303
View File
@@ -1,303 +0,0 @@
// The balance of each token the user tracks is read with balanceOf through the
// RPC endpoint, for every address. Blockscout's answer gives a tracked token its
// holder count and decimals, and adds the tokens the user has not tracked
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
//
// The real refreshBalances() runs against a fake JSON-RPC node, answered
// through ethers' own request hook, and a fake Blockscout, answered through
// fetch().
const { AbiCoder, FetchRequest } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const FIRST = "0x1111111111111111111111111111111111111111";
const SECOND = "0x2222222222222222222222222222222222222222";
// A token the user tracks, outside the bundled list, at 6 decimals.
const TOKEN = "0x7777777777777777777777777777777777777777";
const TRACKED = {
address: TOKEN,
symbol: "TRK",
name: "Tracked Token",
decimals: 6,
};
// USDC, from the bundled list, which the user does not track.
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
// Neither on the bundled list nor tracked.
const OTHER = "0x8888888888888888888888888888888888888888";
const ZERO_WORD = "0x" + "0".repeat(64);
const BALANCE_OF = "0x70a08231";
// What balanceOf answers, by token contract and then by holder. A pair that is
// not here makes balanceOf fail.
let holdings;
// What Blockscout answers for every address; null makes the request fail.
let explorerRows;
let logged;
// One JSON-RPC answer. A call that is not balanceOf is the ENS reverse lookup,
// answered with no resolver, so no name.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
if (req.method === "eth_getBalance") {
return { ...envelope, result: "0x0" };
}
if (req.method !== "eth_call") {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
const { to, data } = req.params[0];
if (!data.startsWith(BALANCE_OF)) return { ...envelope, result: ZERO_WORD };
const holder = "0x" + data.slice(-40);
const amount = (holdings[to.toLowerCase()] || {})[holder];
if (amount === undefined) {
return {
...envelope,
error: { code: -32000, message: "fixture: balanceOf failed" },
};
}
return {
...envelope,
result: AbiCoder.defaultAbiCoder().encode(["uint256"], [amount]),
};
}
const realFetch = globalThis.fetch;
beforeEach(() => {
holdings = {};
explorerRows = null;
logged = [];
jest.spyOn(console, "error").mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
globalThis.fetch = jest.fn(async () => {
if (explorerRows === null) throw new TypeError("Failed to fetch");
return { ok: true, status: 200, json: async () => explorerRows };
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
jest.restoreAllMocks();
});
// One Blockscout token-balances row.
function explorerRow(address, symbol, value, holders = "50000") {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: address,
symbol: symbol,
name: symbol,
decimals: "6",
holders_count: holders,
},
};
}
// The row refreshBalances() writes for TOKEN from a balanceOf answer, with the
// decimals and holder count Blockscout reported for it, if any.
function trackedRow(balance, decimals = null, holders = null) {
return {
address: TOKEN,
name: "Tracked Token",
symbol: "TRK",
decimals: decimals,
balance: balance,
holders: holders,
};
}
async function refresh(addresses, trackedTokens) {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
trackedTokens,
"mainnet",
new AbortController().signal,
);
return addresses;
}
test("with Blockscout failing, a tracked token's balance is read through the RPC endpoint for every address", async () => {
holdings[TOKEN] = { [FIRST]: 1234500000n, [SECOND]: 1n };
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("1234.5")]);
// Exact, never cut: one base unit is not zero.
expect(second.tokenBalances).toEqual([trackedRow("0.000001")]);
});
test("with Blockscout failing, the untracked rows from before stay", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const usdc = {
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [usdc] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0"), usdc]);
});
test("a tracked token Blockscout also reports shows the RPC figure, and an untracked one is admitted as before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [
explorerRow(TOKEN, "TRK", 9000000),
explorerRow(USDC, "USDC", 5000000),
// Too few holders, and neither on the bundled list nor tracked.
explorerRow(OTHER, "SPAM", 5000000, "10"),
// A known symbol from a contract it does not belong to.
explorerRow(OTHER, "USDC", 5000000),
];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
trackedRow("2.0", 6, 50000),
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
]);
});
test("a tracked token's entry carries the holder count and decimals Blockscout reports for it", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
// Fewer holders than "Hide tokens with fewer than 1,000 holders" lets
// through to Send's token list.
explorerRows = [explorerRow(TOKEN, "TRK", 9000000, "10")];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("while Blockscout fails, a tracked token keeps the holder count and decimals of its entry from before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0", 6, 10)] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("a failed balanceOf keeps the token's entry from before, and is logged", async () => {
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const before = trackedRow("7.0", 6, 50000);
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([before]);
expect(logged.some((line) => line.includes("token balance failed"))).toBe(
true,
);
});
test("a failed balanceOf with no entry from before takes Blockscout's entry from this refresh", async () => {
// balanceOf fails at FIRST and answers at SECOND.
holdings[TOKEN] = { [SECOND]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
{
address: TOKEN,
name: "TRK",
symbol: "TRK",
decimals: 6,
balance: "9.0",
holders: 50000,
},
]);
// Where balanceOf answers, its figure stands.
expect(second.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a balance of zero read through the RPC endpoint replaces the previous figure", async () => {
holdings[TOKEN] = { [FIRST]: 0n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0")] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([]);
});
test("a tracked token whose scale nothing knows gets no quantity, not a zero", async () => {
holdings[TOKEN] = { [FIRST]: 5n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow(null)]);
});
test("a tracked token whose scale only Blockscout reports is stated at that scale", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a tracked token wearing a known symbol from another contract is not listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
// Its entry from before goes too, although Blockscout fails.
const before = {
address: OTHER,
name: "Fake",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }],
);
expect(first.tokenBalances).toEqual([]);
});
-1
View File
@@ -161,7 +161,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
); );
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet"); await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet"); expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]); expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
+66 -671
View File
@@ -59,25 +59,6 @@ function encodePermit2(token, amount, spender) {
); );
} }
// Helper: encode a PERMIT2_PERMIT_BATCH input (command 0x03), one allowance
// for each [token, amount], all for `spender`
function encodePermit2Batch(allowances, spender) {
return coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
allowances.map(([token, amount]) => [token, amount, 0, 0]),
spender,
9999999999,
],
"0x1234",
],
);
}
// Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e) // Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e)
function encodeBalanceCheck(owner, token, minBalance) { function encodeBalanceCheck(owner, token, minBalance) {
return coder.encode( return coder.encode(
@@ -715,8 +696,12 @@ describe("uniswap decoder", () => {
USER_ADDR, USER_ADDR,
); );
expect(result.name).toBe("Swap USDT → WETH"); expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(
expect(detail(result, "Token Out").address).toBe(WETH_ADDR); USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
const amount = detail(result, "Amount"); const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT"); expect(amount.value).toBe("Up to 2000.0000 USDT");
@@ -750,7 +735,9 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap ETH → USDC"); expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH"); expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined(); expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Paid to")).toBeUndefined(); expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC"); expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
}); });
@@ -1447,20 +1434,6 @@ describe("uniswap decoder", () => {
USDT_ADDR, USDT_ADDR,
USDC_ADDR, USDC_ADDR,
]); ]);
// The lines of its 600 USDT when there is no Amount line.
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 no line gave the 600 USDT then.
const USDT_600_GROUP = [
["Moved from the sender", "600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// The same for a step's 400 USDT.
const USDT_400_GROUP = [
["Moved from the sender", "400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of // V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`. // `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) => const settle = (currency, amount, payerIsUser) =>
@@ -1687,7 +1660,6 @@ describe("uniswap decoder", () => {
encodeV4SettleAll(USDT_ADDR, USDT_400), encodeV4SettleAll(USDT_ADDR, USDT_400),
], ],
lines: [ lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"], ["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"], ["Moved to", "The swap step that spends it"],
@@ -1708,7 +1680,6 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n), encodeV4TakeAll(USDC_ADDR, 0n),
], ],
lines: [ lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"], ["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"], ["Moved to", "The swap step that spends it"],
@@ -1840,8 +1811,6 @@ describe("uniswap decoder", () => {
// A step that may take from the sender what the decoder did not read // A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so. // leaves no Amount figure, which may be too low, and one line says so.
// The amount of the step that set the input side then gets lines of
// its own.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer; const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([ test.each([
{ {
@@ -1889,7 +1858,6 @@ describe("uniswap decoder", () => {
[V3_600, encodeV4Swap(new Uint8Array(actions), params)], [V3_600, encodeV4Swap(new Uint8Array(actions), params)],
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
]); ]);
}); });
@@ -1912,9 +1880,6 @@ describe("uniswap decoder", () => {
], ],
), ),
], ],
// Nothing read says the user pays this step, so its 400 USDT
// gets no lines of its own.
first: [],
}, },
{ {
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read", kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
@@ -1985,27 +1950,9 @@ describe("uniswap decoder", () => {
), ),
], ],
}, },
// Each take the total would add up gets lines of its own. ])("$kind", ({ commands, inputs }) => {
{
kind: "a V3 step of 600 USDT and a V2 step of 400 USDT the user pays for, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x08, 0x0d],
inputs: [
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
first: [...USDT_600_GROUP, ...USDT_400_GROUP],
},
])("$kind", ({ commands, inputs, first = USDT_600_GROUP }) => {
const result = decodeSteps(commands, inputs); const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
...first,
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
]); ]);
}); });
@@ -2024,7 +1971,6 @@ describe("uniswap decoder", () => {
commands: [0x00, 0x08, 0x21], commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN], inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [ lines: [
...USDT_600_GROUP,
["Moved from the sender", "400.0000 DAI"], ["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"], ["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"], ["Moved to", "The swap step that spends it"],
@@ -2036,7 +1982,6 @@ describe("uniswap decoder", () => {
commands: [0x21, 0x00, 0x08], commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400], inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [ lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"], ["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"], ["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2055,7 +2000,6 @@ describe("uniswap decoder", () => {
SUB_PLAN, SUB_PLAN,
], ],
lines: [ lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"], ["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"], ["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2108,6 +2052,12 @@ describe("uniswap decoder", () => {
const ONE_USDT_LINES = (spender) => const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender); permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
test.each([ test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
lines: ONE_USDT_LINES(OTHER_ADDR),
},
{ {
kind: "that the router lets fail", kind: "that the router lets fail",
commands: [0x8a], commands: [0x8a],
@@ -2156,17 +2106,28 @@ describe("uniswap decoder", () => {
ROUTER_ADDR, ROUTER_ADDR,
); );
// A PERMIT2_PERMIT_BATCH may raise what the router may take of the // A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// permit's token again, and its allowance gets lines of its own. // what the router may take of the permit's token again.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => { test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
const permitBatch = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
[[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT
ROUTER_ADDR,
9999999999,
],
"0x1234",
],
);
const result = decodeSteps( const result = decodeSteps(
[0x0a, 0x03, 0x00, 0x08], [0x0a, 0x03, 0x00, 0x08],
[ [
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
encodePermit2Batch( permitBatch,
[[USDT_ADDR, 10000000000n]], // 10,000 USDT
ROUTER_ADDR,
),
V3_600, V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [ encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR, USDT_ADDR,
@@ -2177,12 +2138,6 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"], ["Amount", "10000.0000 USDT"],
...USDT_600_LINES, ...USDT_600_LINES,
...permitLines(
"10000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]); ]);
}); });
@@ -2233,197 +2188,6 @@ describe("uniswap decoder", () => {
}, },
); );
// https://git.eeqj.de/sneak/AutistMask/issues/655 — a
// PERMIT2_TRANSFER_FROM of the permit's token before the permit moves
// it under whatever allowance the router already had, so it may move
// more than the permit's amount. The permit's amount then does not
// stand, and the Amount line gives the total the steps take, as with
// no permit. On next at f53b503 the first two rows read the permit's
// 600.0000 USDT as the Amount figure, with no lines for the permit.
// One after the permit, as in Uniswap's own V2 routes, moves no more
// than the permit's amount, and one of another token before it moves
// none of the permit's token, so in the last two rows the permit's
// amount stands.
const USDT_2000 = 2000000000n;
// The V2 pair of WETH and USDC on Ethereum Mainnet.
const WETH_USDC_PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// A PERMIT2_TRANSFER_FROM of `amount` of `token` to `recipient`.
const permit2TransferFrom = (token, recipient, amount) =>
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 2,000 USDT into the router, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 2,000 USDT paid from the router, shows the total the steps take and the permit's own lines",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(USDT_ADDR, ROUTER_ADDR, USDT_2000),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_2000,
1n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "2000.0000 USDT"], ...USDT_600_LINES],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 2,000 USDT to another address, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT, shows the total the steps take and the permit's own lines",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(USDT_ADDR, OTHER_ADDR, USDT_2000),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "2000.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", OTHER_ADDR],
...USDT_600_LINES,
],
},
{
kind: "a PERMIT2_PERMIT of 1 WETH, then a PERMIT2_TRANSFER_FROM of 1 WETH to the V2 pair and a V2 exact-in of ALREADY_PAID, shows the permit's amount",
commands: [0x0a, 0x02, 0x08],
inputs: [
encodePermit2(WETH_ADDR, ONE_WETH, ROUTER_ADDR),
permit2TransferFrom(WETH_ADDR, WETH_USDC_PAIR, ONE_WETH),
encodeV2SwapExactIn(USER_ADDR, 0n, 1990000000n, [
WETH_ADDR,
USDC_ADDR,
]),
],
lines: [
["Amount", "1.0000 WETH"],
["Moved from the sender", "1.0000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The V2 pair the next swap spends"],
],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 400 DAI to another address, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT, shows the permit's amount",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(DAI_ADDR, OTHER_ADDR, DAI_400),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", OTHER_ADDR],
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A step that sets the input side and is paid from the router takes
// nothing from the sender, so with no Amount line its amount gets no
// lines of its own: in each route below the sender's 600 USDT moves
// into the router first, and such lines would say it left twice.
const INTO_ROUTER_600 = permit2TransferFrom(
USDT_ADDR,
ROUTER_ADDR,
USDT_600,
);
const V3_600_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_600,
590000000n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
);
const TRANSFER_FROM_BATCH = coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V3 exact-in of 600 USDT paid from the router, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x00, 0x0d],
inputs: [
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_PERMIT that cannot be read, a PERMIT2_TRANSFER_FROM of 600 USDT into the router, then a V3 exact-in of 600 USDT paid from the router",
commands: [0x0a, 0x02, 0x00],
inputs: [
dirtyWord(
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
0,
),
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
],
lines: [["Permit2 allowance", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V4 step swapping 600 USDT whose SETTLE the router pays, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x10, 0x0d],
inputs: [
INTO_ROUTER_600,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_600,
590000000n,
),
settle(USDT_ADDR, USDT_600, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V2 exact-in of CONTRACT_BALANCE paid from the router, then an EXECUTE_SUB_PLAN",
commands: [0x02, 0x08, 0x21],
inputs: [
INTO_ROUTER_600,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
590000000n,
[USDT_ADDR, USDC_ADDR],
false,
],
),
SUB_PLAN,
],
lines: [["Moved from the sender", UNDECODED]],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
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) =>
@@ -2497,10 +2261,10 @@ describe("uniswap decoder", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/370 — a PERMIT2_PERMIT // https://git.eeqj.de/sneak/AutistMask/issues/370 — a PERMIT2_PERMIT
// after the step that set the input side named Token In in place of // after the step that set the input side named Token In in place of
// that step's token, and its amount could stand as the Amount figure. // that step's token, and its amount could stand as the Amount figure.
// Only a permit whose amount stands as the Amount figure names Token // The swap's own token and amount now stay on those lines. That
// In now. Every other, before the swap steps or after them, leaves // permit, and every other whose amount does not stand as the Amount
// Token In and Amount as they read with no permit and gets lines of // figure, gets lines of its own; a permit before the swap steps still
// its own. // names Token In.
describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => { describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => {
const DAI_5000 = 5000000000000000000000n; const DAI_5000 = 5000000000000000000000n;
const USDT_5000 = 5000000000n; const USDT_5000 = 5000000000n;
@@ -2544,6 +2308,20 @@ describe("uniswap decoder", () => {
], ],
lines: USDT_600_LINES, lines: USDT_600_LINES,
}, },
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
),
},
{ {
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,", kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,",
commands: [0x8a, 0x00], commands: [0x8a, 0x00],
@@ -2558,48 +2336,14 @@ describe("uniswap decoder", () => {
ROUTER_ADDR, ROUTER_ADDR,
), ),
}, },
{
kind: "a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 DAI to the router, a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT to the router,",
commands: [0x0a, 0x00, 0x0a],
inputs: [
encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
V3_600,
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
],
lines: [
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
...USDT_600_LINES,
],
},
])( ])(
"$kind shows the swap's 600 USDT and each permit's own lines", "$kind shows the swap's 600 USDT and the permit's own lines",
({ commands, inputs, lines }) => { ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs); const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
// The swap step names Token In, its address checksummed, expect(
// as on a permit's Allowance token line. detail(result, "Token In").address.toLowerCase(),
expect(detail(result, "Token In").value).toBe( ).toBe(USDT_ADDR.toLowerCase());
"USDT (" + USDT_ADDR + ")",
);
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"], ["Amount", "600.0000 USDT"],
...lines, ...lines,
@@ -2608,7 +2352,8 @@ describe("uniswap decoder", () => {
}, },
); );
// With two permits neither amount stands, so the Amount line // The permit before the swap steps still names Token In. With a
// second permit its amount does not stand, so the Amount line
// gives the total the steps take of USDT, the 400 DAI the last // gives the total the steps take of USDT, the 400 DAI the last
// step takes gets lines of its own, and so does each permit. // step takes gets lines of its own, and so does each permit.
test("a PERMIT2_PERMIT of USDT, a V3 exact-in of 600 USDT, a PERMIT2_PERMIT of DAI and a V2 exact-in of 400 DAI shows both tokens' amounts and each permit's own lines", () => { test("a PERMIT2_PERMIT of USDT, a V3 exact-in of 600 USDT, a PERMIT2_PERMIT of DAI and a V2 exact-in of 400 DAI shows both tokens' amounts and each permit's own lines", () => {
@@ -2646,269 +2391,6 @@ describe("uniswap decoder", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/652 — a
// PERMIT2_PERMIT_BATCH, and a PERMIT2_PERMIT this decoder cannot read,
// showed nothing of what they allow. Each allowance a batch grants now
// gets the lines of a permit whose amount does not stand, and a permit
// or batch that cannot be read gets one line saying so and leaves no
// Amount figure.
describe("a PERMIT2_PERMIT_BATCH, or a permit that cannot be read", () => {
const USDT_5000 = 5000000000n;
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
// A batch names one spender for all its allowances. One to
// another spender is under "an allowance to a spender other than
// the router" below.
test("a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to the router, then a V3 exact-in of 600 USDT, shows each allowance", () => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
ROUTER_ADDR,
),
V3_600,
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
...permitLines("Unlimited", DAI_ADDR, "DAI", ROUTER_ADDR),
]);
// A batch pays nobody, so the swap's minimum is what the
// sender receives.
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe(
"590.0000 USDC",
);
});
// A batch keeps a PERMIT2_PERMIT's amount from standing as the
// Amount figure, before the permit as after it.
test("a PERMIT2_PERMIT_BATCH of 5,000 DAI, then a PERMIT2_PERMIT of 5,000 USDT to the router and a V3 exact-in of 600 USDT, shows the swap's 600 USDT and both permits' lines", () => {
const result = decodeSteps(
[0x03, 0x0a, 0x00],
[
encodePermit2Batch([[DAI_ADDR, DAI_5000]], ROUTER_ADDR),
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
V3_600,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]);
});
// The permit's token word, and the batch's spender word, have a
// nonzero top byte.
test.each([
{
kind: "a PERMIT2_PERMIT",
command: 0x0a,
input: dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
},
{
kind: "a PERMIT2_PERMIT_BATCH",
command: 0x03,
input: dirtyWord(
encodePermit2Batch(
[[USDT_ADDR, USDT_5000]],
ROUTER_ADDR,
),
3,
),
},
])(
"$kind that cannot be read, then a V3 exact-in of 600 USDT, says its allowances could not be decoded and shows no Amount figure and no Min. received",
({ command, input }) => {
const result = decodeSteps(
[command, 0x00],
[input, V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Permit2 allowance", UNDECODED],
]);
// Its spender may be any address, as with an allowance
// to a spender other than the router (below).
// https://git.eeqj.de/sneak/AutistMask/issues/658 — on
// next at e296546 this read Min. received.
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(
"590.0000 USDC",
);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 only the 400 DAI was shown.
test("a PERMIT2_PERMIT that cannot be read, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 DAI, both paid by the user, shows both amounts", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x08],
[
dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
...DAI_GROUP,
["Permit2 allowance", UNDECODED],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/658 — code that a step
// runs can call a spender other than the router, which can then take
// up to its allowance from the sender before the transaction ends.
// On next at e296546 the Amount figure and Min. received took no
// account of it. An allowance of Token In to another spender now
// leaves no Amount figure, the swap steps' own takes get lines of
// their own, and any allowance to another spender leaves no Min.
// received.
describe("an allowance to a spender other than the router", () => {
const USDT_5000 = 5000000000n;
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
const USDT_5000_LINES = permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
);
test.each([
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: [...USDT_600_GROUP, ...USDT_5000_LINES],
minimum: "590.0000 USDC",
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 5,000 USDT to another spender,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
],
lines: [...USDT_600_GROUP, ...USDT_5000_LINES],
minimum: "590.0000 USDC",
},
{
kind: "a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x03, 0x00],
inputs: [
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
OTHER_ADDR,
),
V3_600,
],
lines: [
...USDT_600_GROUP,
...USDT_5000_LINES,
...permitLines(
"Unlimited",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
],
minimum: "590.0000 USDC",
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user,",
commands: [0x0a, 0x00, 0x08],
inputs: [
ONE_USDT_PERMIT(OTHER_ADDR),
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
lines: [
...USDT_600_GROUP,
...USDT_400_GROUP,
...ONE_USDT_LINES(OTHER_ADDR),
],
// The last swap step's minimum.
minimum: "390.0000 USDC",
},
])(
"$kind shows each take's own lines and the allowances, and no Amount figure or Min. received",
({ commands, inputs, lines, minimum }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual(lines);
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(minimum);
},
);
// An allowance of another token leaves the Amount figure for
// Token In.
test("a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT, shows the swap's 600 USDT and no Min. received", () => {
const result = decodeSteps(
[0x0a, 0x00],
[encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR), V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(
"590.0000 USDC",
);
});
});
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be // A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
// read. It may pay any address and take from the sender, so it // read. It may pay any address and take from the sender, so it
// leaves no Amount figure. // leaves no Amount figure.
@@ -2918,12 +2400,7 @@ describe("uniswap decoder", () => {
[USER_ADDR, 390000000n, USDT_400, "0x", true], [USER_ADDR, 390000000n, USDT_400, "0x", true],
); );
const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]); const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]);
// With a V3 exact-out step, the 600 USDT reads as a maximum, as
// the Amount line would.
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
]); ]);
expect(detail(result, "Paid to").value).toBe(UNDECODED); expect(detail(result, "Paid to").value).toBe(UNDECODED);
@@ -2974,9 +2451,6 @@ describe("uniswap decoder", () => {
], ],
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 0.5000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR), ...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]); ]);
@@ -3132,15 +2606,14 @@ describe("uniswap decoder", () => {
); );
// The V4 exact-out route above the other way round, where the later // The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no Amount figure and the 400 USDT gets // take's unknown amount leaves no figure at all, and other ways the
// lines of its own, and other ways the step that sets the input side // step that sets the input side states no amount.
// states no amount.
test.each([ test.each([
{ {
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT", kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10], commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600], inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: [...UP_TO_400_USDT, ...UNKNOWN_USDT], lines: UNKNOWN_USDT,
}, },
{ {
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT", kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
@@ -3730,88 +3203,6 @@ describe("uniswap decoder", () => {
}, },
); );
// https://git.eeqj.de/sneak/AutistMask/issues/574 — a V4 step that buys
// at least 0.5 WETH with 1,000 USDT, spends some of it on USDC, and takes
// the USDC and then the WETH, so its output side is WETH. Only what is
// left of the 0.5 WETH is sure to reach the user, and the screen read
// Min. received 0.5000 WETH.
test.each([
{
spend: "0.3 WETH for at least 594 USDC",
swap: {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
300000000000000000n,
594000000n,
),
},
},
{
spend: "0.3 WETH through DAI for at least 594 USDC",
swap: {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
WETH_ADDR,
[DAI_ADDR, USDC_ADDR],
300000000000000000n,
594000000n,
),
},
},
{
spend: "at most 0.3 WETH for exactly 594 USDC",
swap: {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
594000000n,
300000000000000000n,
),
},
},
])(
"a V4 step that buys WETH and spends $spend, with WETH as its output side, shows no Min. received",
({ swap }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
swap.id,
V4_SETTLE_ALL_ID,
V4_TAKE_ID,
V4_TAKE_ID,
]),
[
USDT_FOR_WETH_IN.param,
swap.param,
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4Take(USDC_ADDR),
encodeV4Take(WETH_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two // A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the // currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown. // decoder does not read, names no side, so its output side is unknown.
@@ -3862,7 +3253,9 @@ describe("uniswap decoder", () => {
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
@@ -3920,7 +3313,9 @@ describe("uniswap decoder", () => {
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT"); expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
}); });
+14 -42
View File
@@ -1332,10 +1332,6 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
// The second swap action spends 1 USDC of what the first buys,
// so the step states no minimum
// (https://git.eeqj.de/sneak/AutistMask/issues/574).
noMin: true,
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles", kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles",
@@ -1436,24 +1432,15 @@ describe("any token left in the router is not received", () => {
), ),
left: { label: "Token moved into the router", value: "ETH" }, left: { label: "Token moved into the router", value: "ETH" },
}, },
])("$kind", ({ data, left, spent, unread, noMin }) => { ])("$kind", ({ data, left, spent, unread }) => {
const result = decode(data); const result = decode(data);
expect(labels(result)).toEqual([ expect(labels(result)).toEqual([
"Protocol", "Protocol",
"Token In", "Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read // A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place // may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625), // of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
// after the lines the WRAP_ETH's 1 ETH then gets unread ? "Moved from the sender" : "Amount",
// (https://git.eeqj.de/sneak/AutistMask/issues/659).
...(unread
? [
"Moved from the sender",
"Token moved",
"Moved to",
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token // A later swap step the user pays for spends `spent` of a token
// other than Token In. // other than Token In.
...(spent ...(spent
@@ -1462,7 +1449,7 @@ describe("any token left in the router is not received", () => {
"Token Out", "Token Out",
"Paid to", "Paid to",
"Token moved into the router", "Token moved into the router",
...(noMin ? [] : ["Min. paid out"]), "Min. paid out",
"Steps", "Steps",
"Deadline", "Deadline",
]); ]);
@@ -1470,17 +1457,13 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent); expect(detail(result, "Moved from the sender").value).toBe(spent);
} }
if (unread) { if (unread) {
expect( expect(detail(result, "Moved from the sender").value).toBe(
result.details UNDECODED,
.filter((d) => d.label === "Moved from the sender") );
.map((d) => d.value),
).toEqual(["1.0000 ETH", UNDECODED]);
} }
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left); expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out")?.value).toBe( expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
noMin ? undefined : "1990.0000 USDC",
);
}); });
// Uniswap's own routes: a swap paid in ETH wraps into the router what its // Uniswap's own routes: a swap paid in ETH wraps into the router what its
@@ -2808,37 +2791,26 @@ describe("the allow-revert flag", () => {
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// The permit's spender is the router at ROUTER, which on every other
// router is an allowance to another spender, so the minimum reads
// Min. paid out there.
test.each([ test.each([
{ { router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{ {
router: "UniversalRouterV1_2_V2Support", router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2, address: ROUTER_V1_2,
mark: "", mark: "",
minimum: "Min. paid out",
}, },
{ {
router: "UniversalRouterV2", router: "UniversalRouterV2",
address: ROUTER, address: ROUTER,
mark: " (allowed to fail)", mark: " (allowed to fail)",
minimum: "Min. received",
}, },
{ {
router: "UniversalRouterV2_1_2", router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2, address: ROUTER_V2_1_2,
mark: " (allowed to fail)", mark: " (allowed to fail)",
minimum: "Min. paid out",
}, },
])( ])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag", "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag",
({ address, mark, minimum }) => { ({ address, mark }) => {
const steps = [ const steps = [
permit2Permit(USDT, USDT_2000), permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER), swapToWeth(MSG_SENDER),
@@ -2855,8 +2827,7 @@ describe("the allow-revert flag", () => {
// The permit names the router at ROUTER as its spender, so its // The permit names the router at ROUTER as its spender, so its
// amount stands as the Amount figure only there, and only without // amount stands as the Amount figure only there, and only without
// the flag. With the flag it stands on no router and gets lines // the flag. With the flag it stands on no router and gets lines
// of its own, and the swap step names Token In: the same token, // of its own.
// its address written the same way.
const permitLines = [ const permitLines = [
"Permit2 allowance", "Permit2 allowance",
"Allowance token", "Allowance token",
@@ -2864,13 +2835,14 @@ describe("the allow-revert flag", () => {
]; ];
const swapLines = (r) => const swapLines = (r) =>
r.details.filter( r.details.filter(
(d) => !["Steps", ...permitLines].includes(d.label), (d) =>
d.label !== "Steps" && !permitLines.includes(d.label),
); );
expect(swapLines(withFlag)).toEqual(swapLines(plain)); expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect( expect(
labels(withFlag).filter((l) => permitLines.includes(l)), labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines); ).toEqual(permitLines);
expect(detail(withFlag, minimum).value).toBe("1.0000 WETH"); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
}, },
); );