Author SHA1 Message Date
sneak 30b089a3f5 fix: the address screen shows a token just added at its fetched balance, not 0.0000 (closes #516)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A balance refresh that returns now draws the balances on AddressDetail or
AddressToken again when that screen is up, not only the wallet list; their
transaction lists are left alone. Every add path on AddToken and
SettingsAddToken tracks a token through one step, trackToken(), which also
lists it in the new stored field tokensNotFetched: its line reads `quantity
unknown` instead of 0.0000 until a refresh that started with it tracked has
fetched every address's token balances, even if the popup closes first. To
tell an address whose token balances the explorer did not return, the refresh
takes them off its copy of the wallets before fetching.

Model: opus-5-5
2026-10-21 04:46:34 +00:00
clawbot 4be625dc06 fix: an allowance to another spender leaves no Amount figure for its token and no Min. received (closes #658)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Code that a step runs, such as a V4 pool's hook, 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. The Amount figure and Min. received took
no account of it.

In decode(), an allowance of Token In to a spender other than the router
now leaves no Amount line, as an unread permit already did, so the swap
steps' own takes get lines of their own. Any allowance to another spender,
or a permit or batch the decoder cannot read, makes the minimum read
Min. paid out. Permits to the router read as before.

Model: opus-5-5
2026-10-21 06:24:51 +02:00
clawbot e2965469ba fix: with no Amount line, the user's takes the total adds up get lines of their own (closes #659)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When a step this decoder cannot read, an unreadable permit, or a take
of Token In with an unknown amount held back the Amount line, the
amount the step that sets the input side states, and each later take
of Token In added to it, appeared on no line, while other takes kept
their own Moved from the sender lines. Those takes now get the same
lines, first. The first step's amount gets them only when the user
pays that step (a V2 or V3 step with payerIsUser true, a V4 step the
user pays some of its input token, or a WRAP_ETH): one paid from the
router takes nothing from the wallet. Routes with an Amount line read
as before.

Model: opus-5-5
2026-10-21 05:15:21 +02:00
clawbot 2691702f80 fix: an open popup follows a network switch the background saves (closes #650)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When the background saves a network switch after the user approves a
site's request, or another popup page saves one, an open popup takes the
new network from the stored record inside a save, holding the lock, and
checks the record once at start-up for a switch saved before it
listened. The network it takes is not counted as the popup's own change,
even when that save fails to write. It then leaves the page as a switch
in Settings does. A switch in Settings is now made inside its own save
too, so one picked while a follow waits for the lock is made after it.
The popup never switches the network itself.

Model: opus-5-5
2026-10-21 04:26:51 +02:00
clawbot 5f2b606754 fix: a PERMIT2_TRANSFER_FROM before a permit keeps its amount from standing as the Amount figure (closes #655)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_TRANSFER_FROM that runs before a PERMIT2_PERMIT of the same
token moves it under whatever allowance the router already had, so it
can move more than the permit's amount. The permit's amount still stood
as the Amount figure, which could then read lower than what leaves the
wallet.

decode() now counts such a transfer, into the router or anywhere else,
among the steps before the permit that took its token, so the permit no
longer stands: the Amount line gives the total the steps take, as with
no permit, and the permit gets its own lines. A transfer after the
permit, or of another token before it, still lets it stand; tests cover
both. README Transaction Decoding states the condition.

Model: opus-5-5
2026-10-21 03:16:51 +02:00
clawbot f53b503b15 fix: a PERMIT2_PERMIT_BATCH, and a permit that cannot be read, state what they allow (closes #652)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_PERMIT_BATCH was never read, and a PERMIT2_PERMIT whose input
could not be read got no lines, so the approval screen showed nothing of
the allowances either grants.

Each allowance of a batch now gets the three lines a permit whose amount
does not stand gets: Permit2 allowance, Allowance token and Allowance
spender. A permit or batch that cannot be read gets one Permit2 allowance
line reading Unknown (could not be decoded) and leaves no Amount line.
Any batch in the transaction keeps a PERMIT2_PERMIT's amount from
standing. A batch pays nobody, so it no longer adds a Paid to line.
README Transaction Decoding and the TxApproval element list match.

Model: opus-5-5
2026-10-21 01:41:35 +02:00
clawbot 45aead6340 fix: only a permit whose amount is the Amount figure names Token In (closes #370)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_PERMIT named Token In whether or not its amount stood as the
Amount figure, so the approval screen could show a permit's token, or an
allowance, where the swap's own input belonged.

Now only a permit whose amount stands names Token In. Every other
permit, before the swap steps or after them, leaves Token In and Amount
as they read with no permit and gets three lines of its own: Permit2
allowance, Allowance token and Allowance spender. A V3 swap step's
tokens are checksummed, as every other decoded address is, so a token's
address reads the same on every line. README Transaction Decoding, its
unknown token scale passage and the TxApproval element list match.

Model: opus-5-5
2026-10-21 00:56:41 +02:00
clawbot e517219038 fix: a balance refresh that returns after a network switch writes nothing (closes #577)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The popup's doRefreshAndRender() and the background's backgroundRefresh()
each note the network a refresh starts on and write its balances only if
that network is still the active one when the requests return. Otherwise
nothing is written and lastBalanceRefresh stays as the switch left it. The
background checks inside updateState(). The popup refreshes a copy of its
wallets and copies the results onto `state` from inside its save, under the
lock, only if both its own network and the stored record's still match, so
a switch the background makes is caught too. saveState() takes an optional
function, run there on the stored record before the page's state is
compared, that may change `state` or return false to cancel the write.

Model: opus-5-5
2026-10-20 22:59:51 +02:00
30 changed files with 2194 additions and 352 deletions
+151 -85
View File
@@ -461,13 +461,15 @@ address, the transaction assertions run against the raw signed transaction
captured at `eth_sendRawTransaction` rather than against anything the extension
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
stored network unchanged and send no `chainChanged` until it is approved, a
network switch in Settings is required to send the page `chainChanged` with the
new chain id, a prompt raised while another approval window has focus is
required to open a window of its own, and the password is required to be absent
from every message the approval window sends to the background — with the
message that would carry it required to be present, so that check cannot pass by
observing nothing. That last one is the standing floor under
stored network unchanged and send no `chainChanged` until it is approved, the
popup open all along is required to show the `[TESTNET]` banner once a switch to
Sepolia is approved and to drop it once the switch back is, a network switch in
Settings is required to send the page `chainChanged` with the new chain id, a
prompt raised while another approval window has focus is required to open a
window of its own, and the password is required to be absent from every message
the approval window sends to the background — with the message that would carry
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -603,9 +605,10 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows. The `personal_sign` prompt is also
required to fit its window without scrolling sideways, with the extension's
pages at 170% zoom.
background page and approval windows. The popup open all along is required to
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop
it once the switch back is. The `personal_sign` prompt is also required to fit
its window without scrolling sideways, with the extension's pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
@@ -1129,13 +1132,15 @@ 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
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
(`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received`
lines (`src/shared/uniswap.js`). The token permission warning on the signature
screen takes the same rule for its amounts. An unbounded allowance or permit
needs no scale to describe and is still shown as `Unlimited`. A source's answer
counts only if it is a whole number from 0 to 80: `decimals()` returns a
`uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a
token that reports 81 to 255 is shown as one whose scale nothing knows.
(`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`,
`Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of
a move into the router (`src/shared/uniswap.js`). The token permission warning
on the signature screen takes the same rule for its amounts. An unbounded
allowance or permit needs no scale to describe and is still shown as
`Unlimited`. A source's answer counts only if it is a whole number from 0 to 80:
`decimals()` returns a `uint8`, but `formatUnits()` cannot format more than 80
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
sources are read as authoritative, so a value written into one of them cannot be
@@ -1168,8 +1173,8 @@ fetched, without that cross-address check, and shows the row's figure.
**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
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid, see Transaction Decoding), do not always read as a
number. They can read:
only the user is paid and no allowance goes to a spender other than the router,
see Transaction Decoding), do not always read as a number. They can read:
- 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.
@@ -1391,7 +1396,9 @@ lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
zero balance" is on; a token just added on AddToken or SettingsAddToken whose
balance no refresh has fetched yet is listed there as `quantity unknown` (see
AddToken).
#### Stored state and its version
@@ -1562,20 +1569,36 @@ 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
while the user was away from it.
A network switch in Settings leaves every screen beneath Settings showing the
previous network, so after it "Back" behaves as in a reopened popup: every
screen on the stack counts as not yet rendered, and the stack is cut at the
first screen that cannot be rendered from saved state, such as Send or AddToken,
or at AddressToken for any token but the native one, because that token was
picked on the previous network. The cut stack is saved with the switch, so a
popup closed straight after it and reopened onto Settings lands in the same
place. "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, because it starts at Welcome, which shows
nothing from a network.
The popup refreshes balances every ten seconds while it is open
(`doRefreshAndRender()` in `src/popup/index.js`). Each refresh that returns
draws the wallet list again and, when AddressDetail or AddressToken is up, that
screen's USD total and balance lines; its transaction list is left alone. No
other screen is drawn again by a refresh, DeleteAddress and its balance lines
included.
A network switch leaves every screen on the page showing the previous network.
The user makes it in Settings, or approves a site's request and the background
saves it. Any other popup open meanwhile takes the switch from the stored
record, even one saved while it was opening, and never switches the network
itself. A switch the user picks in its Settings while it takes one is made after
it, not overwritten by it. An approval window does not take the switch. After
the switch, whichever way the page got it, "Back" behaves as in a reopened
popup: every screen on the stack counts as not yet rendered, and the stack is
cut at the first screen that cannot be rendered from saved state, such as Send
or AddToken, or at AddressToken for any token but the native one, because that
token was picked on the previous network and is no longer selected. The cut
stack is saved with the popup's new network, so a popup closed straight after
and reopened lands in the same place. The screen on top is drawn again for the
new network: Settings in place, and any other screen as a reopened popup draws
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
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
@@ -2064,7 +2087,14 @@ view would leave a wallet one click from deletion.
- "Add" button
- **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**
**AddressDetail**. The token's balance is not known until a balance
refresh that started with it tracked has fetched every address's token
balances; until then its balance line reads `quantity unknown`, never
`0.0000`, unless the explorer had already listed a holding of it. That
holds after the popup is closed and opened again, as the tokens still
waiting for a fetch are stored with the profile. A refresh that returns
draws AddressDetail's balances again (see Navigation), so the fetched
balance shows without the screen being reopened
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup
@@ -2092,8 +2122,11 @@ view would leave a wallet one click from deletion.
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
network it was added on, because a contract address names a token on one
network only. Choosing the network already active changes nothing and
sends nothing
network only. Switching also clears every address's balances. A balance
refresh, by the popup or the background, stores what it fetched only if
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
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2338,7 +2371,9 @@ view would leave a wallet one click from deletion.
add path
- **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change)
"Added SYMBOL" flash message (no screen change). Until its balance is
fetched, its balance line reads `quantity unknown`, as after "Add" on
AddToken
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the
@@ -2423,10 +2458,11 @@ view would leave a wallet one click from deletion.
- Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, what each
`PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender
take, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that a token
stays in the router or who it pays is unknown (see Transaction Decoding)
`PERMIT2_PERMIT` whose amount is not the `Amount` figure, and each
`PERMIT2_PERMIT_BATCH`, lets its spender take, steps, deadline, and unless
a swap pays only the From address, a `Paid to` line for each other address
it pays, or saying that a token stays in the router or who it pays is
unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
@@ -3179,8 +3215,9 @@ Currently supported:
`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
From address on TxApproval, the sender the explorer reports on
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
`Token Out` name each other address paid; one reads
TransactionDetail), no `Paid to` line is shown, and no permit or batch grants
an allowance to a spender other than the router (see below). `Paid to` lines
after `Token Out` name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
@@ -3215,37 +3252,56 @@ Currently supported:
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
the decoder does not know a take's amount, the total is not known and there is
no `Amount` line. `Token In` is the token of the last `PERMIT2_PERMIT` the
decoder reads before the step that sets the input side, when there is one. Its
amount, `Unlimited` included, stands as the `Amount` figure in place of the
total only in the plain shape of Uniswap's own routes: it is the only
`PERMIT2_PERMIT` in the transaction, its spender is the router, the router may
not let it fail, no step before it takes its token or a token the decoder does
not know, and the decoder reads every step after it in full. Permit2 then lets
the router take no more of the token than that amount, so the permit covers
every take of it. A step the decoder does not read in full may raise what
Permit2 lets the router take, as a `PERMIT2_PERMIT_BATCH` can: 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. In any other case
the line gives the total, as with no permit. When the step that sets the input
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount,
`Unlimited` included, stands as the `Amount` figure in place of the total,
which it does only in the plain shape of Uniswap's own routes: it comes before
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
the router may not let it fail, no step before it takes its token, a
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know,
and the decoder reads every step after it in full. Permit2 then lets the
router take no more of the token than that amount, so the permit covers every
take of it. A step before the permit takes under whatever allowance the router
already had, so it may take more than the permit's amount, which would then
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3
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,
its amount gets lines of its own, unless it states no amount and the user pays
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
that step's amount gets lines of its own, unless it states no amount and the
user pays it none of its input token. Each take the total does not count gives
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
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
whose amount the decoder does not know. 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, the step that sets the input side first when it has them:
whose amount the decoder does not know. When there is no `Amount` line, each
take the total counts gets them too, so that every amount the decoder reads is
shown: the amount the step that sets the input side states, when it is a
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose
ETH comes from the transaction's value), and each later take of `Token In`
added to it. A step that sets the input side and is paid from the router takes
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give
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
`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
@@ -3269,24 +3325,34 @@ Currently supported:
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
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. A `PERMIT2_PERMIT` after the
step that sets the input side names neither `Token In` nor the `Amount`
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
where it goes, and so its amount is shown once. 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
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. So does one whose
spender is not the router, or that the router may let fail. Only a permit
whose amount stands gives none: the `Amount` line gives its amount, `Token In`
its token, and its spender is the router. These lines change neither the
`Paid to` lines nor the minimum's label. 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`,
`Allowance spender`, the address it lets take the token. Only a permit whose
amount stands gives none: the `Amount` line gives its amount, `Token In` its
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH`
grants, one for each token it names, all for its one spender, gives the same
three lines, in step order with those of each `PERMIT2_PERMIT`. A
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot
read, such as one whose token or spender word has a nonzero top byte, may let
any spender take any amount of any token: it gives one `Permit2 allowance`
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`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
+8
View File
@@ -1379,6 +1379,13 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// 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() {
const s = await getState();
const now = Date.now();
@@ -1402,6 +1409,7 @@ async function backgroundRefresh() {
}
await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
+100 -4
View File
@@ -5,12 +5,14 @@ const {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const {
$,
showView,
@@ -22,6 +24,7 @@ const {
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
onStorageChanged,
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
@@ -68,24 +71,83 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh.
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.
//
// Once the save is done, the wallet list is drawn again, and so are the
// balances on AddressDetail or AddressToken when that is the screen up. No
// other screen is drawn again, DeleteAddress and its balance lines included.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
try {
const networkId = state.networkId;
// The tracked tokens this refresh asks the explorer about. A token
// added on AddToken after this point is not among them, so this
// refresh does not count as having fetched it.
const tokens = state.trackedTokens.map((t) => t.address.toLowerCase());
const wallets = structuredClone(state.wallets);
// Taken off the copy, so that an address whose token balances the
// explorer did not return comes back without any and keeps its
// stored ones.
for (const wallet of wallets) {
for (const addr of wallet.addresses) delete addr.tokenBalances;
}
await Promise.all([
refreshPrices(),
refreshBalances(
state.wallets,
wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
state.networkId,
networkId,
pageClosed.signal,
),
]);
state.lastBalanceRefresh = Date.now();
await saveState();
const refreshed = new Map();
let allTokenBalancesFetched = true;
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
if (addr.tokenBalances === undefined) {
allTokenBalancesFetched = false;
}
}
}
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];
}
}
}
// Each token this refresh asked about is now fetched for every
// address.
if (allTokenBalancesFetched) {
state.tokensNotFetched = state.tokensNotFetched.filter(
(token) => !tokens.includes(token),
);
}
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
@@ -116,6 +178,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
restoreView,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -183,6 +246,28 @@ function fallbackView() {
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
// 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
@@ -327,6 +412,17 @@ async function init() {
settingsAddToken.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) {
showView("welcome");
} 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
// asks for one when the user switches the network, because every screen
// then on the page shows the previous network (views/settings.js); the
// asks for one after a network switch, because every screen then on the page
// shows the previous network (showNewNetwork() in views/settings.js); the
// unit tests, which simulate several page loads against one module
// instance, ask for one too.
function resetRenderedViews() {
+9 -2
View File
@@ -1,4 +1,11 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -72,7 +79,7 @@ function init(ctx) {
return;
}
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
trackToken({
address: contractAddr,
symbol: info.symbol,
decimals: info.decimals,
+17 -8
View File
@@ -65,11 +65,25 @@ function show() {
});
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
renderBalances();
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
// The USD total and the balance list. Drawn again by each balance refresh
// that returns while this screen is up (doRefreshAndRender() in
// src/popup/index.js), which leaves the rest of the screen as it is.
function renderBalances() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
$("address-balances").innerHTML = balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -83,11 +97,6 @@ function show() {
ctx.showAddressToken();
});
});
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
let loadedTxs = [];
@@ -280,4 +289,4 @@ function init(_ctx) {
exportPrivkey.init();
}
module.exports = { init, show };
module.exports = { init, show, renderBalances };
+45 -32
View File
@@ -48,37 +48,18 @@ function show() {
const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress;
const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(symbol);
}
currentSymbol = symbol;
currentSymbol =
tokenId === "ETH"
? nativeCurrency()
: resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens);
$("address-token-title").textContent =
wallet.name +
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
displaySymbol(currentSymbol);
// Blockie
const blockieEl = $("address-token-jazzicon");
@@ -100,13 +81,7 @@ function show() {
$("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
renderBalance();
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
@@ -168,6 +143,44 @@ function show() {
loadTransactions(addr.address, tokenId);
}
// The USD total and the balance line of the selected token. Drawn again by
// each balance refresh that returns while this screen is up
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
// screen as it is.
function renderBalance() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has fetched a
// token just added: no quantity to show, and none to price.
// balanceLine() states that rather than printing 0.0000.
if (tb) amount = unknownableAmount(tb.balance);
else amount = state.tokensNotFetched.includes(id) ? null : 0;
price = getPrice(currentSymbol);
}
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(
currentSymbol,
amount,
price,
);
}
async function loadTransactions(address, tokenId) {
try {
const rawTxs = await fetchRecentTransactions(
@@ -343,4 +356,4 @@ function init(_ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, renderBalance };
+17 -3
View File
@@ -380,6 +380,18 @@ function balanceLine(symbol, amount, price, tokenId) {
);
}
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does, and lists it in `state.tokensNotFetched`: no refresh
// has fetched its balance yet. An address with no balance row for it has no
// known balance of it, so its line says the quantity is unknown rather than
// 0.0000. doRefreshAndRender() in src/popup/index.js takes it off once a
// refresh that started with it tracked has fetched every address's token
// balances (https://git.eeqj.de/sneak/AutistMask/issues/516).
function trackToken(token) {
state.trackedTokens.push(token);
state.tokensNotFetched.push(token.address.toLowerCase());
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
@@ -409,12 +421,13 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
}
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
const id = t.address.toLowerCase();
if (seen.has(id)) continue;
html += balanceLine(
t.symbol,
0,
state.tokensNotFetched.includes(id) ? null : 0,
getPrice(t.symbol),
t.address.toLowerCase(),
id,
);
}
}
@@ -786,6 +799,7 @@ module.exports = {
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
trackToken,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
+61 -28
View File
@@ -38,6 +38,8 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
@@ -243,7 +245,51 @@ async function renderSiteLists() {
);
}
function init(ctx) {
// A network switch, made by the user here or saved by the background once the
// 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);
showPhrase.init();
@@ -319,37 +365,18 @@ function init(ctx) {
const networkSelect = $("settings-network");
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;
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");
}
cutViewStack();
const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
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);
showNewNetwork();
showFlash("Switched to " + net.name + ".");
});
@@ -484,4 +511,10 @@ function init(ctx) {
});
}
module.exports = { init, show, renderSiteLists };
module.exports = {
init,
show,
renderSiteLists,
cutViewStack,
showNewNetwork,
};
+11 -4
View File
@@ -1,4 +1,11 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -43,7 +50,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
state.trackedTokens.push(token);
trackToken(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -110,7 +117,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
state.trackedTokens.push(token);
trackToken(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -156,7 +163,7 @@ function init(_ctx) {
return;
}
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
trackToken({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
+14
View File
@@ -224,6 +224,19 @@ function storageRemove(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`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
@@ -385,6 +398,7 @@ module.exports = {
getPlatformInfo,
hasBrowserNamespace,
notify,
onStorageChanged,
runtimeApi,
sendMessage,
sessionStorageGet,
+37 -17
View File
@@ -1,8 +1,18 @@
// Consolidated chain-switch handler for the popup.
// The popup's network switch.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, future chain additions) all go
// through a single code path.
// A network switch reaches the popup's `state` in one of two ways:
//
// - onChainSwitch() below makes the switch the user picks in Settings, with
// 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
// networks.js — no per-caller wiring is needed.
@@ -20,22 +30,32 @@
const { applyChainSwitchFields } = require("./chainSwitchFields");
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.
// Returns the network configuration object for the new chain.
// Returns the network configuration object for the new chain, or null when
// `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) {
const { state, saveState } = require("./state");
const net = applyChainSwitchFields(state, newNetworkId);
// --- price cache ---
// 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();
let net = null;
await saveState(() => {
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId);
clearPopupCaches();
});
return net;
}
module.exports = { onChainSwitch };
module.exports = { onChainSwitch, clearPopupCaches };
+31 -2
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the object it is
// Everything here is synchronous and touches nothing but the objects it 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
// microseconds earlier and is about to be written back.
@@ -76,4 +76,33 @@ function applyChainSwitchFields(s, newNetworkId) {
return net;
}
module.exports = { applyChainSwitchFields };
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
// 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 };
+9
View File
@@ -28,6 +28,13 @@ const DEFAULT_STATE = {
// others are restored from when the active network changes. See
// applyChainSwitchFields().
networkTrackedTokens: {},
// Contract addresses, lowercased, of the tokens the user added that no
// balance refresh has fetched yet. trackToken() in
// src/popup/views/helpers.js adds one, and doRefreshAndRender() in
// src/popup/index.js takes it off. Stored, so a token added just before
// the popup closed still reads `quantity unknown` when it opens again
// (https://git.eeqj.de/sneak/AutistMask/issues/516).
tokensNotFetched: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -412,6 +419,8 @@ function normalizePersisted(saved) {
// so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts);
// Contract addresses too, read with `includes()` and `filter()`.
out.tokensNotFetched = textList(saved.tokensNotFetched);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
+36 -12
View File
@@ -23,6 +23,7 @@
// guarantee.
const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
@@ -125,8 +126,10 @@ function currentNetwork() {
}
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began. saveState() diffs
// the live state against this to find only the fields THIS page changed since.
// finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -466,12 +469,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// 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
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it
// 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());
// before this fix; only the persisted record is guaranteed current. The
// network is the exception: the popup takes a switch another context saved
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js).
async function saveStateOnce(beforeWrite) {
const result = await storageGet("autistmask");
// 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,
@@ -485,6 +486,12 @@ async function saveStateOnce() {
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js.
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 };
for (const key of PERSISTED_FIELDS) {
@@ -579,10 +586,15 @@ function reportSaveFailure(err) {
// 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
// asked for at once; they take the lock in the order they were asked, and each
// compares the page's state as it stands when its turn begins against the
// baseline the save before it left.
function saveState() {
const turn = withLock("autistmask", saveStateOnce);
// compares the page's state, as it stands once the stored record has been read
// and `beforeWrite` has run, against the baseline the save before it left.
//
// `beforeWrite`, when given, is called inside the lock with the stored record
// 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
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
@@ -590,6 +602,17 @@ function saveState() {
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
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -635,6 +658,7 @@ module.exports = {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
currentAddress,
currentNetwork,
+174 -67
View File
@@ -336,7 +336,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// 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
// 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.
// takes from the sender, when this decoder does not know it, and for what a
// permit it could not read allows.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or
@@ -528,6 +529,32 @@ 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
// check when `owner` holds at least `minBalance` of `token`, and reads `owner`
// as written, without map(), on every release that has the command:
@@ -597,12 +624,14 @@ function decodeV2SwapExactOut(input) {
// 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
// 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.
// of such a path are not the token it reads there. Both are checksummed, as
// the ABI decoder gives every other address, so a token reads the same on
// every line.
function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = "0x" + hex.slice(0, 40);
const last = "0x" + hex.slice(-40);
const first = getAddress("0x" + hex.slice(0, 40));
const last = getAddress("0x" + hex.slice(-40));
return { first, last };
}
@@ -1205,17 +1234,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
// Whether the user pays the V4 step that set the input side any of
// its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read.
// Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
// Every PERMIT2_PERMIT this decoder read, before a step set the input
// side or after, in step order, as { token, amount, spender, step }.
// Each whose amount is not the Amount figure gets lines of its own
// (see after the PERMIT2_TRANSFER_FROM lines).
// Every allowance a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH grants,
// before a step set the input side or after, in step order, as
// { token, amount, spender, step }. A permit or batch whose input
// this decoder cannot read is one with a null token, amount and
// 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 = [];
// 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
@@ -1274,8 +1307,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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
// from a different hop. A PERMIT2_PERMIT sets no side here: after the
// loop, the last one read before a step set the input side names
// Token In, and the Amount line is formed there, in one place (see
// loop, one whose amount stands as the Amount figure names Token In,
// and the Amount line is formed there, in one place (see
// permitStands).
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
@@ -1320,6 +1353,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const spentUnread = () => {
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.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
@@ -1475,21 +1517,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permits.push({ ...p, step: i });
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
permitUnread(i);
}
// After a step set the input side, a permit names
// neither Token In nor the Amount figure: the swap's own
// input token and amount stay on those lines.
// Only a permit whose amount stands as the Amount figure
// names Token In (see permitStands). Every other permit,
// before the step that sets the input side or after it,
// 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) {
// Whether a step before it took its token, or may
// have: one in `userPaid`, when its token is the
// permit's or not known. No step has set the input
// side yet.
// have: one in `userPaid`, `movedIntoRouter` or
// `movedFromSender`, when its token is the permit's
// or not known. No step has set the input side yet.
const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token);
const tookBefore = userPaid.some((t) =>
mayBeItsToken(t.token),
);
const tookBefore = [
...userPaid,
...movedIntoRouter,
...movedFromSender,
].some((t) => mayBeItsToken(t.token));
permit = {
...p,
step: i,
@@ -1497,6 +1544,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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") {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
@@ -1569,6 +1625,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1592,6 +1649,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1623,6 +1681,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
@@ -1718,10 +1777,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
if (!inputEstablished) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
inputPaidByUser = true;
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
@@ -1905,8 +1967,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
// Any other step may pay an address this decoder does not
// read: PERMIT2_PERMIT_BATCH and the NFT commands among
// them, and a type the release names no command for.
// read: the NFT commands among them, and a type the
// release names no command for.
payeeUnread = true;
stepsUnread.push(i);
}
@@ -1952,22 +2014,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure.
//
// The last PERMIT2_PERMIT read before a step set the input side names
// Token In. Its amount stands as the Amount figure in place of the
// total only in the plain shape of Uniswap's own routes: it is the
// only PERMIT2_PERMIT in the transaction, its spender is the router,
// the router may not let it fail, no step before it took its token or
// one not known, and this decoder read every step after it in full
// (see stepsUnread). Permit2 then lets the router take no more of the
// token than that amount, so the permit covers every take of it. A
// later step this decoder did not read may raise what Permit2 lets
// the router take, as a PERMIT2_PERMIT_BATCH can. A step that may
// take from the sender what this decoder did not read leaves no
// figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// step leaves both sides unknown (below). A PERMIT2_PERMIT after a
// step set the input side counts among the PERMIT2_PERMITs here.
// Every PERMIT2_PERMIT but one whose amount stands gets lines of its
// own below.
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
// plain shape of Uniswap's own routes: it comes before the step that
// set the input side, it is the only PERMIT2_PERMIT in the
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is
// the router, the router may not let it fail, no step before it took
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token
// included), and this decoder read every step after it in full (see
// stepsUnread). Permit2 then lets the router take no more of the token
// than that amount, so the permit covers every take of it. A step
// before it takes under whatever allowance the router already had, so
// it may take more. A later permit or batch, or a later step this
// decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A step
// that may take from the sender what this decoder did not read leaves
// 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(
commandsBytes,
(b) => commands[b & commandTypeMask],
@@ -1975,6 +2041,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const permitStands =
permit !== null &&
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail &&
permit.beforeEveryTake &&
@@ -1984,6 +2051,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// (see spentUnread()), or a token it does not know.
const spentFromSender = [];
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
// 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
@@ -1994,17 +2066,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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.
let firstTakeUnstated = null;
if (permit === null || sameAddress(inputToken, permit.token)) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
} else if (hasFirstTake) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so the total starts with no figure.
// permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount });
}
if (permit !== null) inputToken = permit.token;
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken);
if (ofTokenIn && permitStands) continue;
@@ -2014,6 +2089,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof t.amount === "bigint"
) {
total += t.amount;
inTotal.push(t);
continue;
}
spentFromSender.push(t);
@@ -2037,10 +2113,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step, and a permit among
// them would name Token In. What the other steps state is
// then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
// or pay the user, before or after any other step. What the other
// steps state is then not the whole swap, so both sides are set
// unknown, with no figure, wherever the step stands.
if (hasSubPlan) {
inputToken = null;
inputAmount = null;
@@ -2143,12 +2218,30 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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
// or a token it does not know, leaves no Amount figure, which may
// then be too low.
// or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be
// 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 (
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);
details.push({
@@ -2156,6 +2249,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
value: amount.display,
rawValue: amount.raw,
});
} else {
spentFromSender.unshift(...inTotal);
}
// What steps take from the sender beyond the Amount line, in the shape
@@ -2198,16 +2293,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
);
}
// What each PERMIT2_PERMIT lets its spender take: its amount, its
// token and the spender, an address on a line of its own. Permit2's
// maximum is unbounded. Only a permit whose amount stands as the
// Amount figure (see permitStands) gets none: that line already gives
// its amount and Token In its token, and its spender is the router.
// Every other permit, one after the step that set the input side
// included, gets these lines, so nothing it lets anyone take goes
// unstated.
// What each allowance of a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH lets
// its spender take: its amount, its token and the spender, an address
// on a line of its own. Permit2's maximum is unbounded. Only a permit
// whose amount stands as the Amount figure (see permitStands) gets
// none: that line already gives its amount and Token In its token,
// and its spender is the router. Every other allowance, one after the
// step that set the input side included, gets these lines, so nothing
// it lets anyone take goes unstated. A permit or batch this decoder
// could not read gets one line saying so.
for (const p of permits) {
if (permitStands && p.step === permit.step) continue;
if (!present(p.token)) {
details.push({
label: "Permit2 allowance",
value: UNDECODED_CURRENCY,
});
continue;
}
details.push({
label: "Permit2 allowance",
value:
@@ -2259,9 +2362,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
// step pays the sender, there is no such line, and no allowance goes
// to another spender (see toAnotherSpender). When no step names anyone
// paid, one line says so. `paidTo` holds these lines and the token
// line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -2308,7 +2412,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
const received = paidToSender && paidTo.length === 0;
const received =
paidToSender &&
paidTo.length === 0 &&
toAnotherSpender.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
+273
View File
@@ -0,0 +1,273 @@
// After "Add" on AddToken, AddressDetail lists the new token, and its line reads
// `quantity unknown`, not 0.0000, until a balance refresh that asked the
// explorer about the token has returned for every address, the popup closing
// and opening again meanwhile included; so does a token added on
// SettingsAddToken, wherever it is listed. A refresh that returns draws the
// balances on AddressDetail or AddressToken again, so the fetched balance shows
// without the screen being reopened
// (https://git.eeqj.de/sneak/AutistMask/issues/516). Driven through the real
// popup over a stored profile on Ethereum Mainnet, with "Show tracked tokens
// with zero balance" on, as it is by default.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
// The explorer's row for a holding of a million NEW_TOKEN, in the shape
// fetchTokenBalances() stores it.
const MILLION = {
address: NEW_TOKEN,
name: "New Token",
symbol: "NEW",
decimals: 18,
balance: "1000000.0",
holders: 3,
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail for the one address.
function bootOnAddress() {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as the explorer's
// rows for it, or with no token balances at all when given null: an explorer
// that did not answer, which the real refreshBalances() leaves unwritten.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
refreshBalances.mockImplementationOnce(async (wallets) => {
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
if (tokenBalances !== null) {
wallets[0].addresses[0].tokenBalances = tokenBalances;
}
});
return async (tokenBalances) => {
release(tokenBalances);
await page.settle();
};
}
// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup
// answering at once.
async function addNewToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-add-token");
page.node("add-token-address").value = NEW_TOKEN;
await page.click("btn-add-token-confirm");
}
// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
// another is named, as markup.
function newTokenRow(page, id = "address-balances") {
return page
.node(id)
.innerHTML.split("<div ")
.find((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
const { state } = require("../src/shared/state");
state.selectedWallet = 0;
state.selectedAddress = 0;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressDetail").show();
await page.settle();
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(page, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
}
test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
const finish = holdNextRefresh(page);
await addNewToken(page);
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await finish([MILLION]);
});
test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
// Stands for the transactions on the screen, which only show() replaces.
page.node("tx-list").innerHTML = "transactions on the screen";
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("1000000.0000");
// Only the balances are drawn again, not the transaction list.
expect(page.node("tx-list").innerHTML).toBe("transactions on the screen");
});
test("a refresh already running when the token is added does not count as fetching it", async () => {
const page = await bootOnAddress();
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addNewToken(page);
await finishRunning([]);
await ticking;
expect(newTokenRow(page)).toContain("quantity unknown");
// The next one asks the explorer about the token, which lists no holding
// of it: a fetched zero.
const finishNext = holdNextRefresh(page);
const next = page.tick();
await page.settle();
await finishNext([]);
await next;
expect(newTokenRow(page)).toContain("0.0000");
});
test("closed and opened again before any refresh has fetched it, the new token's line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
holdNextRefresh(closed);
await addNewToken(closed);
cleanupPopup();
// The refresh this boot starts gets no token balances back: the boot's
// stub of refreshBalances() writes none.
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish([MILLION]);
await ticking;
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh in which the explorer did not answer leaves the new token's line quantity unknown", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await finish(null);
// Drawn again: the ETH balance it fetched is shown.
expect(page.node("address-balances").innerHTML).toContain("2.0000");
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await finish([MILLION]);
expect(page.node("address-token-balance").innerHTML).toContain(
"1000000.0000",
);
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]);
const finish = holdNextRefresh(page);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-settings");
await page.click("btn-settings-add-token");
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
const onHome = newTokenRow(page, "wallet-list");
expect(onHome).toContain("quantity unknown");
expect(onHome).not.toContain("0.0000");
await openAddress(page);
const onAddress = newTokenRow(page);
expect(onAddress).toContain("quantity unknown");
expect(onAddress).not.toContain("0.0000");
await finish([MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that returns while AddressToken is up draws its balance again", async () => {
const page = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
expect(page.node("address-token-balance").innerHTML).toContain("1.5000");
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish(null);
await ticking;
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
+49 -1
View File
@@ -19,7 +19,10 @@
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// A network switch landing inside a balance refresh's round trip is covered at
// 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
// holds against any implementation that gets the outcome right.
@@ -395,3 +398,48 @@ describe("a balance refresh under another handler's state read", () => {
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,
);
});
});
+161
View File
@@ -0,0 +1,161 @@
// 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);
});
+4 -1
View File
@@ -446,7 +446,10 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
await require("../src/shared/state").loadState();
const { state, loadState } = require("../src/shared/state");
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
+12
View File
@@ -867,6 +867,14 @@ step(
"the page was not told of the approved switch: " +
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 d.click("#btn-approve-network");
@@ -881,6 +889,10 @@ step(
isDeepStrictEqual(await storedNetwork(env), before),
"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);
},
);
+11 -1
View File
@@ -651,7 +651,8 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the
// screen, and nothing renders the list again while the screen is up.
// screen. The refresh it starts draws the list again, with the row still
// in it.
const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol },
@@ -4865,6 +4866,12 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " +
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(
env,
@@ -4886,6 +4893,9 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before),
"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
+1
View File
@@ -88,6 +88,7 @@ describe.each([
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
trackToken: () => {},
}));
require("../src/popup/views/" + view).init({
+7
View File
@@ -248,6 +248,13 @@ const CONTRACT = [
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText),
},
{
field: "tokensNotFetched",
kind: KIND.ENTRIES,
hostile: [42, "notalist", { a: 1 }],
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText),
},
{
field: "viewStack",
kind: KIND.ENTRIES,
+300
View File
@@ -0,0 +1,300 @@
// 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 = {};
globalThis.chrome = {
storage: { local: storage.local },
storage: { local: storage.local, onChanged: storage.onChanged },
runtime: {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
+30 -5
View File
@@ -18,7 +18,9 @@
// a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
// `changed` is handed what each set or remove changed, in the shape
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -34,14 +36,25 @@ function makeArea(store, tick) {
const set = jest.fn(async (items) => {
tick();
const changes = {};
for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]);
}
changed(changes);
});
const remove = jest.fn(async (key) => {
tick();
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
const changes = {};
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
});
return { get, set, remove };
@@ -54,12 +67,22 @@ function makeArea(store, tick) {
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
// storage.onChanged: after a set or remove has finished, every listener is
// 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 {
// Drop this straight in as chrome.storage.
local,
session: makeArea({}, tick),
session: makeArea({}, tick, announce("session")),
onChanged: { addListener: (fn) => listeners.push(fn) },
get: local.get,
set: local.set,
remove: local.remove,
@@ -71,7 +94,9 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this".
// test — for standing in as "another page wrote this". Nothing is
// 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) => {
store[key] = structuredClone(value);
},
+1
View File
@@ -161,6 +161,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
+589 -66
View File
@@ -59,6 +59,25 @@ 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)
function encodeBalanceCheck(owner, token, minBalance) {
return coder.encode(
@@ -696,12 +715,8 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT");
@@ -735,9 +750,7 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
});
@@ -1434,6 +1447,20 @@ describe("uniswap decoder", () => {
USDT_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
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
@@ -1660,6 +1687,7 @@ describe("uniswap decoder", () => {
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1680,6 +1708,7 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1811,6 +1840,8 @@ describe("uniswap decoder", () => {
// 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.
// 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;
test.each([
{
@@ -1858,6 +1889,7 @@ describe("uniswap decoder", () => {
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
]);
});
@@ -1880,6 +1912,9 @@ 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",
@@ -1950,9 +1985,27 @@ describe("uniswap decoder", () => {
),
],
},
])("$kind", ({ commands, inputs }) => {
// Each take the total would add up gets lines of its own.
{
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);
expect(linesBetweenTokens(result)).toEqual([
...first,
["Moved from the sender", UNDECODED],
]);
});
@@ -1971,6 +2024,7 @@ describe("uniswap decoder", () => {
commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [
...USDT_600_GROUP,
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1982,6 +2036,7 @@ describe("uniswap decoder", () => {
commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2000,6 +2055,7 @@ describe("uniswap decoder", () => {
SUB_PLAN,
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2052,12 +2108,6 @@ describe("uniswap decoder", () => {
const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
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",
commands: [0x8a],
@@ -2106,28 +2156,17 @@ describe("uniswap decoder", () => {
ROUTER_ADDR,
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again.
// A PERMIT2_PERMIT_BATCH may raise what the router may take of the
// permit's token again, and its allowance gets lines of its own.
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(
[0x0a, 0x03, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
permitBatch,
encodePermit2Batch(
[[USDT_ADDR, 10000000000n]], // 10,000 USDT
ROUTER_ADDR,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
@@ -2138,6 +2177,12 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
...permitLines(
"10000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]);
});
@@ -2188,6 +2233,197 @@ 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`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
@@ -2261,10 +2497,10 @@ describe("uniswap decoder", () => {
// 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
// that step's token, and its amount could stand as the Amount figure.
// The swap's own token and amount now stay on those lines. That
// permit, and every other whose amount does not stand as the Amount
// figure, gets lines of its own; a permit before the swap steps still
// names Token In.
// Only a permit whose amount stands as the Amount figure names Token
// In now. Every other, before the swap steps or after them, leaves
// Token In and Amount as they read with no permit and gets lines of
// its own.
describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => {
const DAI_5000 = 5000000000000000000000n;
const USDT_5000 = 5000000000n;
@@ -2308,20 +2544,6 @@ describe("uniswap decoder", () => {
],
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,",
commands: [0x8a, 0x00],
@@ -2336,14 +2558,48 @@ describe("uniswap decoder", () => {
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 the permit's own lines",
"$kind shows the swap's 600 USDT and each permit's own lines",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
expect(
detail(result, "Token In").address.toLowerCase(),
).toBe(USDT_ADDR.toLowerCase());
// The swap step names Token In, its address checksummed,
// as on a permit's Allowance token line.
expect(detail(result, "Token In").value).toBe(
"USDT (" + USDT_ADDR + ")",
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...lines,
@@ -2352,8 +2608,7 @@ describe("uniswap decoder", () => {
},
);
// The permit before the swap steps still names Token In. With a
// second permit its amount does not stand, so the Amount line
// With two permits neither amount stands, so the Amount line
// 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.
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", () => {
@@ -2391,6 +2646,269 @@ 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
// read. It may pay any address and take from the sender, so it
// leaves no Amount figure.
@@ -2400,7 +2918,12 @@ describe("uniswap decoder", () => {
[USER_ADDR, 390000000n, USDT_400, "0x", true],
);
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([
["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],
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
@@ -2451,6 +2974,9 @@ describe("uniswap decoder", () => {
],
);
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],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]);
@@ -2606,14 +3132,15 @@ describe("uniswap decoder", () => {
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
// take's unknown amount leaves no Amount figure and the 400 USDT gets
// lines of its own, and other ways the step that sets the input side
// states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
lines: [...UP_TO_400_USDT, ...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",
@@ -3253,9 +3780,7 @@ describe("uniswap decoder", () => {
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
@@ -3313,9 +3838,7 @@ describe("uniswap decoder", () => {
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(USDT_ADDR);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
+33 -11
View File
@@ -1439,8 +1439,17 @@ describe("any token left in the router is not received", () => {
"Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625),
// after the lines the WRAP_ETH's 1 ETH then gets
// (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
// other than Token In.
...(spent
@@ -1457,9 +1466,11 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
expect(
result.details
.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(detail(result, "Token moved into the router")).toEqual(left);
@@ -2791,26 +2802,37 @@ describe("the allow-revert flag", () => {
});
// 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([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
minimum: "Min. received",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
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",
({ address, mark }) => {
({ address, mark, minimum }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
@@ -2827,7 +2849,8 @@ describe("the allow-revert flag", () => {
// The permit names the router at ROUTER as its spender, so its
// amount stands as the Amount figure only there, and only without
// the flag. With the flag it stands on no router and gets lines
// of its own.
// of its own, and the swap step names Token In: the same token,
// its address written the same way.
const permitLines = [
"Permit2 allowance",
"Allowance token",
@@ -2835,14 +2858,13 @@ describe("the allow-revert flag", () => {
];
const swapLines = (r) =>
r.details.filter(
(d) =>
d.label !== "Steps" && !permitLines.includes(d.label),
(d) => !["Steps", ...permitLines].includes(d.label),
);
expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
expect(detail(withFlag, minimum).value).toBe("1.0000 WETH");
},
);