Author SHA1 Message Date
clawbot 08d760d195 fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The swap's Amount line took only the first swap step's amount. Each later
step the user pays for now adds what it takes of Token In, and what it
takes of another token, or an amount the decoder cannot tell, gets lines
of its own. A V4 step counts every SETTLE paid by the user and every
SETTLE_ALL, with or without a swap action. A step that may take from the
sender what the decoder did not read leaves no Amount figure and gets one
line saying so. Only a permit to the router that the router does not let
fail still covers later takes of its token.

Model: opus-5-5
2026-10-19 23:53:32 +00:00
59 changed files with 695 additions and 5372 deletions
+174 -353
View File
@@ -461,15 +461,13 @@ address, the transaction assertions run against the raw signed transaction
captured at `eth_sendRawTransaction` rather than against anything the extension captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, the stored network unchanged and send no `chainChanged` until it is approved, a
popup open all along is required to show the `[TESTNET]` banner once a switch to network switch in Settings is required to send the page `chainChanged` with the
Sepolia is approved and to drop it once the switch back is, a network switch in new chain id, a prompt raised while another approval window has focus is
Settings is required to send the page `chainChanged` with the new chain id, a required to open a window of its own, and the password is required to be absent
prompt raised while another approval window has focus is required to open a from every message the approval window sends to the background — with the
window of its own, and the password is required to be absent from every message message that would carry it required to be present, so that check cannot pass by
the approval window sends to the background — with the message that would carry observing nothing. That last one is the standing floor under
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -502,12 +500,9 @@ papered over:
forced by clicking Reveal and the settings gear in one page task, which a forced by clicking Reveal and the settings gear in one page task, which a
person cannot do; the case a person can hit, the first decrypt after the popup person cannot do; the case a person can hit, the first decrypt after the popup
opens while libsodium is still loading, is not driven. opens while libsodium is still loading, is not driven.
- Approval windows open at 1280x720 whatever size the extension asks for. The - Approval windows open at 1280x720 whatever size the extension asks for, so
site-connection prompt and the transaction approval are checked for sideways whether an approval screen fits its window is checked in the Firefox suite
scrolling at 396x600 by sizing the page itself for that measurement only, and only.
the rest of each test runs at 1280x720. That leaves out the window's frame and
the page widening its window, so whether an approval screen fits its window is
checked in the Firefox suite only.
Any test that drives a failure path on purpose declares the `console.error` it Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration is about to provoke, via `errors.expect()`. That is not a mute: the declaration
@@ -605,10 +600,9 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script, rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows. The popup open all along is required to background page and approval windows. The `personal_sign` prompt is also
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop required to fit its window without scrolling sideways, with the extension's
it once the switch back is. The `personal_sign` prompt is also required to fit pages at 170% zoom.
its window without scrolling sideways, with the extension's pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and **no npm dependencies at all**: it is built on global `fetch` and
@@ -1122,25 +1116,22 @@ with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no **Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`. scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user tracks It is resolved from the bundled token list, then from the tokens the user
on the network the transaction is for, then, when that is the active network, tracks, then from what the block explorer reported for the contract
from what the block explorer reported for the contract in the wallet's balances, (`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
which are the active network's only (`resolveTokenDecimals()` in answers, the amount is not formatted: the line reads
`src/shared/approvalAmount.js`). Where none of them answers, the amount is not `5000000000 base units (decimals unknown)`. A guessed scale is not an
formatted: the line reads `5000000000 base units (decimals unknown)`. A guessed approximation but a different number — 1,000 units of a 6-decimal token
scale is not an approximation but a different number — 1,000 units of a formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen to state what is being authorized. Both amount paths of that screen take this
whose only job is to state what is being authorized. Both amount paths of that rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
screen take this rule: the ERC-20 `transfer`/`approve` line the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
(`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`, token permission warning on the signature screen takes the same rule for its
`Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of amounts. An unbounded allowance or permit needs no scale to describe and is
a move into the router (`src/shared/uniswap.js`). The token permission warning still shown as `Unlimited`. A source's answer counts only if it is a whole
on the signature screen takes the same rule for its amounts. An unbounded number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
allowance or permit needs no scale to describe and is still shown as format more than 80 decimal places, so a token that reports 81 to 255 is shown
`Unlimited`. A source's answer counts only if it is a whole number from 0 to 80: as one whose scale nothing knows.
`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 The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -1173,8 +1164,8 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line **Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid and no allowance goes to a spender other than the router, only the user is paid, see Transaction Decoding), do not always read as a
see Transaction Decoding), do not always read as a number. They can read: number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: - A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above. truncated to four decimals, with the floor and the zero cases described above.
@@ -1184,18 +1175,18 @@ see Transaction Decoding), do not always read as a number. They can read:
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256` - `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT` above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
or exact-out or `WRAP_ETH` amount that large, which is not an allowance. The V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
amount. amount.
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2 or - `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
V3 exact-out step, whichever step set the line, including the `WRAP_ETH` of a exact-out step, whichever step set the line, including the `WRAP_ETH` of a
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure, swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
not necessarily all of it; the wait, success and error screens show it with not necessarily all of it; the wait, success and error screens show it with
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their `Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows wording. When a V2 exact-out step sets `Min. received`, that line shows its
its `amountOut`, the exact amount it buys. `amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -1249,40 +1240,12 @@ only when no earlier step set it. It finds each side by three rules:
`Amount` or `Min. received`. A side they did not name is unknown, and the `Amount` or `Min. received`. A side they did not name is unknown, and the
step sets it as unknown with no figure. step sets it as unknown with no figure.
Under each of these rules, when the user pays the step any of the input
currency, its `Amount` is instead what the user pays it of that currency when
the decoder knows that amount, and otherwise there is none (see Transaction
Decoding). So a step whose USDT-for-USDC swap action could not be read, with a
`SETTLE` of 400 USDT paid by the user, reads `Amount 400.0000 USDT`.
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`, A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names. `TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when no step set the output An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
side, or when the output side is WETH, on mainnet or Sepolia, that the step that on mainnet or Sepolia, or when no step set the output side; a WETH
set it paid to the router and that the router still holds when the `UNWRAP_WETH` `Min. received` figure then reads in ETH. Otherwise `Token Out` and
runs (Transaction Decoding below gives what the router holds). The router
unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone
else, stays WETH: a swap that pays the router WETH, then a swap that pays the
user WETH, then an unwrap, shows WETH with the second swap's minimum. The
minimum reads in ETH only when that step paid all its WETH to the router and
every step between it and the `UNWRAP_WETH` is one the decoder read of these: a
`PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`,
`PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH, but
not of ETH to a WETH contract, which pays the router WETH. None of these can
take WETH out of the router or pay anyone WETH; the decoder treats every other
step as one that may. Otherwise the swap shows ETH with no minimum, as it does
after a V4 step that `TAKE`s some of its WETH to the user and the rest to the
router, or that pays the router WETH beside a payout the decoder cannot read,
and with a WETH interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`:
the decoder does not count how much of that step's minimum reaches the
`UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. When an `UNWRAP_WETH`
of that WETH pays the router, or its recipient cannot be read, the router may
hold the ETH, and a later step may pay it out as WETH, as a `WRAP_ETH` of it to
the user does: after any later step but those listed, the swap shows WETH with
no minimum. Once the swap shows no minimum, or shows WETH this way, no later
step, a later `UNWRAP_WETH` included, brings back the minimum or ETH; only a
later step that sets the output side sets it anew. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap `Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on unwraps the WETH it did not spend shows USDC. The token permission warning on
@@ -1396,9 +1359,7 @@ lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on; a token just added on AddToken or SettingsAddToken whose zero balance" is on.
balance no refresh has fetched yet is listed there as `quantity unknown` (see
AddToken).
#### Stored state and its version #### Stored state and its version
@@ -1408,19 +1369,6 @@ carries a `schemaVersion` — `STATE_SCHEMA_VERSION` in
`saveState()` and the background's `updateState()` both do, so whichever context `saveState()` and the background's `updateState()` both do, so whichever context
wrote last, the record says which build's shape it is in. wrote last, the record says which build's shape it is in.
Each write reads the record, changes it and writes it back, holding a lock from
the read to the write: the browser's own (`navigator.locks`), taken through
`withLock()` in `src/shared/browserApi.js`. Every page of the extension and its
background share it, except a page in a Firefox private window, which Firefox
gives locks of its own (see https://git.eeqj.de/sneak/AutistMask/issues/646).
The popup's `saveState()`, the background's `updateState()` and the
StateRecovery reset all take it, so among the pages and the background that
share it, no write lands between another write's read and its write, where it
would be lost. Chrome has it from version 69, older than any Chrome that runs a
Manifest V3 extension, in pages and in the service worker; Firefox from version
96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
write fails rather than going ahead without it.
Version 1 is the shape that shipped before versions existed, so a stored record Version 1 is the shape that shipped before versions existed, so a stored record
with no `schemaVersion` is version 1 rather than a defect: it loads normally and with no `schemaVersion` is version 1 rather than a defect: it loads normally and
is migrated in place by being stamped on the first write. An upgrade never shows is migrated in place by being stamped on the first write. An upgrade never shows
@@ -1569,36 +1517,18 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed "Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it. while the user was away from it.
The popup refreshes balances every ten seconds while it is open A network switch in Settings leaves every screen beneath Settings showing the
(`doRefreshAndRender()` in `src/popup/index.js`). Each refresh that returns previous network, so after it "Back" behaves as in a reopened popup: every
draws the wallet list again and, when AddressDetail or AddressToken is up, that screen on the stack counts as not yet rendered, and the stack is cut at the
screen's USD total and balance lines; its transaction list is left alone. No first screen that cannot be rendered from saved state, such as Send or AddToken,
other screen is drawn again by a refresh, DeleteAddress and its balance lines or at AddressToken for any token but the native one, because that token was
included. 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
A network switch leaves every screen on the page showing the previous network. place. "Back" then renders the screen it lands on for the new network, except
The user makes it in Settings, or approves a site's request and the background ConfirmTx, which keeps the network its transaction was reviewed on, and anything
saves it. Any other popup open meanwhile takes the switch from the stored typed there and not saved, which belonged to the previous network, is dropped.
record, even one saved while it was opening, and never switches the network Without a wallet the stack is not cut, because it starts at Welcome, which shows
itself. A switch the user picks in its Settings while it takes one is made after nothing from a network.
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 Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every `onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
@@ -2036,10 +1966,8 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`) #### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row on Home, AddressDetail, or - **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken. The screen describes the transaction on the network it was sent AddressToken. Its etherscan links open the explorer of the network the
on, whichever network is active: its block, network details, decoded details transaction was sent on, whichever network is active.
and raw data are read from that network's Blockscout, the one Settings has for
it, and its etherscan links open that network's explorer.
- **Elements** (grouped into logical blocks using light well containers; field - **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings): labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
@@ -2047,11 +1975,7 @@ view would leave a wallet one click from deletion.
- Type: transaction classification — one of: Native ETH Transfer, ERC-20 - Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
transfer with a token contract is an ERC-20 Token Transfer whatever symbol transfer with a token contract is an ERC-20 Token Transfer whatever symbol
the token reports. A call named `execute` or `multicall` is a Swap, here the token reports.
and on its history row, only when it was sent to one of the Universal
Router addresses Uniswap publishes for the network it was sent on (see
Transaction Decoding); sent to any other contract, its history row shows
the method name and this line reads Contract Call.
- Status: "Success" or "Failed" - Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link; - From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available ENS name if available
@@ -2087,14 +2011,7 @@ view would leave a wallet one click from deletion.
- "Add" button - "Add" button
- **Transitions**: - **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**. The token's balance is not known until a balance **AddressDetail**
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 - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
@@ -2122,11 +2039,8 @@ view would leave a wallet one click from deletion.
none, and the tokens tracked on that network, and sends `chainChanged` none, and the tokens tracked on that network, and sends `chainChanged`
with the new chain id to every open tab. A token is tracked only on the with the new chain id to every open tab. A token is tracked only on the
network it was added on, because a contract address names a token on one network it was added on, because a contract address names a token on one
network only. Switching also clears every address's balances. A balance network only. Choosing the network already active changes nothing and
refresh, by the popup or the background, stores what it fetched only if sends nothing
the stored network is still the one it fetched on when it returns, so the
previous network's balances never reach the new one. Choosing the network
already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2371,9 +2285,7 @@ view would leave a wallet one click from deletion.
add path add path
- **Transitions**: - **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an - Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change). Until its balance is "Added SYMBOL" flash message (no screen change)
fetched, its balance line reads `quantity unknown`, as after "Add" on
AddToken
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
- "Add" by contract address, then a network switch in Settings before the - "Add" by contract address, then a network switch in Settings before the
@@ -2456,13 +2368,11 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a - Site origin (bold, scheme and port included) + "wants to send a
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give, amounts, what each swap step the user pays for spends of a token other
what each `PERMIT2_TRANSFER_FROM` moves and where to, what each than `Token In`, what each `PERMIT2_TRANSFER_FROM` moves and where to,
`PERMIT2_PERMIT` whose amount is not the `Amount` figure, and each steps, deadline, and unless a swap pays only the From address, a `Paid to`
`PERMIT2_PERMIT_BATCH`, lets its spender take, steps, deadline, and unless line for each other address it pays, or saying that a token stays in the
a swap pays only the From address, a `Paid to` line for each other address router or who it pays is unknown (see Transaction Decoding)
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 - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -2475,11 +2385,6 @@ view would leave a wallet one click from deletion.
- Raw data: full calldata displayed inline (shown if present) - Raw data: full calldata displayed inline (shown if present)
- Password input and an error line - Password input and an error line
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Behavior**: Every etherscan link on the screen opens the explorer of the
network the transaction was populated on, whichever network is active. The
Value and Network fee lines each give the USD value of the exact amount at the
price on that network, as on ConfirmTx: none for a testnet transaction or one
on a chain id that names no network.
- **Transitions**: - **Transitions**:
- "Confirm" (correct password) → decrypts and signs the transaction it was - "Confirm" (correct password) → decrypts and signs the transaction it was
shown, exactly as shown, hands the signed transaction to the background to shown, exactly as shown, hands the signed transaction to the background to
@@ -3088,37 +2993,31 @@ Currently supported:
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
The swap summary is shown only for a transaction sent to one of the Universal The swap summary is shown only for a transaction sent to one of the Universal
Router addresses Uniswap publishes for the network: on TxApproval the network Router addresses Uniswap publishes for the network: on TxApproval the network
the transaction is for, on TransactionDetail the network it was sent on. The the transaction is for, on TransactionDetail the active network. The same
same calldata sent to any other address is shown as raw data. Decodes Permit2, calldata sent to any other address is shown as raw data. Decodes Permit2,
V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V3 swap step's path is read V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only
as the router reads it: a `V3_SWAP_EXACT_IN` path runs from the token it in the parameter shape deployed on mainnet and Sepolia, not in the newer one
spends to the token it buys, and a `V3_SWAP_EXACT_OUT` path from the token it on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
buys to the token it spends, so that step's input token is its path's last any other shape is not read, and its step is listed as
token and its output token the first. The router reads a path as one token `V4 Swap (could not be decoded)`. Each step is named and read as the command
address followed by whole hops of a 3-byte fee and a token address, and that the release of the router at that address runs for its command type, as
ignores any bytes after the last hop, so the decoder does not read a path with that release's `contracts/libraries/Commands.sol` names it. The decoder reads
such bytes. A V4 swap action is read only in the parameter shape deployed on the type from the command byte with the mask that router uses: `0x1f` for
mainnet and Sepolia, not in the newer one on `v4-periphery` `main`, which adds `UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and
a `minHopPriceX36` field. An action in any other shape is not read, and its `UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and
step is listed as `V4 Swap (could not be decoded)`. Each step is named and `UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not
read as the command that the release of the router at that address runs for change the command a step is read as. Releases run different commands for some
its command type, as that release's `contracts/libraries/Commands.sol` names types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to
it. The decoder reads the type from the command byte with the mask that router `0x13` are NFT commands, where the later routers run `V4_SWAP`,
uses: `0x1f` for `UniversalRouterV1`, `0x3f` for `V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for `V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
(`0x80`) therefore does not change the command a step is read as. Releases run on `UniversalRouterV1`). A type a release names no command for is listed by
different commands for some types: on `UniversalRouterV1` and its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
`UniversalRouterV1_2_V2Support`, `0x10` to `0x13` are NFT commands, where the `BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
later routers run `V4_SWAP`, `V3_POSITION_MANAGER_PERMIT`, transaction when a step fails, unless the step's command byte has that flag
`V3_POSITION_MANAGER_CALL` and `V4_INITIALIZE_POOL`. The decoder does not read set and its command is one the router lets fail, as each release's
those routers' NFT commands, and lists each by its name there, for example `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`Seaport` for `SEAPORT` (`0x10` on `UniversalRouterV1`). A type a release
names no command for is listed by its number, for example `Command 0x0e` on
`UniversalRouterV1`, which has no `BALANCE_CHECK_ERC20` and reverts on it. The
router reverts the whole transaction when a step fails, unless the step's
command byte has that flag set and its command is one the router lets fail, as
each release's `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`, `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
`PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`, `PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
`V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL` `V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
@@ -3181,43 +3080,40 @@ Currently supported:
as a swap paid in ETH does, but only when that amount is not as a swap paid in ETH does, but only when that amount is not
`CONTRACT_BALANCE` and the router held no WETH from an earlier step when the `CONTRACT_BALANCE` and the router held no WETH from an earlier step when the
`WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1 `WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1
WETH from it reads as leaving the WETH there. A V2 or V3 exact-out step paid WETH from it reads as leaving the WETH there. A `SETTLE` of `OPEN_DELTA`
from the router spends an amount the calldata does not state, at most its (zero), and a `SETTLE_ALL`, pay only what the step still owes of their token
`amountInMax`, so the decoder counts it as spending nothing the router holds. when they run. The decoder takes that to be the amounts the step's swap
A `SETTLE` of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step actions state they spend of that token only when every action in the step
still owes of their token when they run. The decoder takes that to be the could be read, every swap action names the token it spends and the token it
amounts the step's swap actions state they spend of that token only when every buys (a swap action with an empty path swaps nothing), the `SETTLE` or
action in the step could be read, every swap action names the token it spends `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and
and the token it buys (a swap action with an empty path swaps nothing), the comes after every swap action, no swap action buys that token, no `TAKE`,
`SETTLE` or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount
token and comes after every swap action, no swap action buys that token, no adds to what the step owes), and the swap actions that spend it state a
`TAKE`, `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a quantity together; otherwise it does not know the amount, and such a `SETTLE`
stated amount adds to what the step owes), and the swap actions that spend it from the router counts as spending nothing. So the rest of a V4 `SETTLE` of
state a quantity together; otherwise it does not know the amount, and such a more than its swap spends, taken to the router, the WETH of a `WRAP_ETH`, and
`SETTLE` from the router counts as spending nothing. So the rest of a V4 a token a swap pays the router on the way to the output, each stays in the
`SETTLE` of more than its swap spends, taken to the router, the WETH of a router when later steps do not spend all of it, where anyone can take it in a
`WRAP_ETH`, and a token a swap pays the router on the way to the output, each later transaction. The router sends ETH as written, so a `TRANSFER` of
stays in the router when later steps do not spend all of it, where anyone can `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
take it in a later transaction. The router sends ETH as written, so a token to the V2 pair that the very next step spends, as a V2 exact-in of that
`TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
step paying a token to the V2 pair that the very next step spends, as a V2 hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
exact-in of that token whose `amountIn` is `ALREADY_PAID` (zero) or the router enforces that swap's own minimum. The decoder checks that the
`CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes pay the address paid is that pair, deriving it from the V2 factory and pair init code
next V2 pair directly, and the router enforces that swap's own minimum. The hash the network's routers were deployed with and the first two tokens of that
decoder checks that the address paid is that pair, deriving it from the V2 step's path; a payment to any other address is named. (Sepolia's
factory and pair init code hash the network's routers were deployed with and `UniversalRouterV1_2_V2Support` was deployed with placeholders for both
the first two tokens of that step's path; a payment to any other address is values, so no V2 swap step can succeed on that router.) Every other payment
named. (Sepolia's `UniversalRouterV1_2_V2Support` was deployed with counts, in whichever step and of whatever token, a share of the output
placeholders for both values, so no V2 swap step can succeed on that router.) included: an interface fee paid to another address by `PAY_PORTION`,
Every other payment counts, in whichever step and of whatever token, a share `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
of the output included: an interface fee paid to another address by because the swap's minimum includes the fee. The minimum reads `Min. received`
`PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads only when some step pays the transaction's sender (the From address on
`Min. paid out`, because the swap's minimum includes the fee. The minimum TxApproval, the sender the explorer reports on TransactionDetail) and no
reads `Min. received` only when some step pays the transaction's sender (the `Paid to` line is shown. `Paid to` lines after `Token Out` name each other
From address on TxApproval, the sender the explorer reports on 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 `The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example, bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` `The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
@@ -3229,136 +3125,61 @@ Currently supported:
name); and one more reads `Unknown (could not be decoded)` when a step that name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the `Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. The `Amount` line gives the total the steps minimum reads `Min. paid out`. The `Amount` line gives the amount of the step
take of `Token In`, from the step that sets the input side on: the first V2 or that sets the input side: the last `PERMIT2_PERMIT` step if there is one,
V3 exact-in or exact-out, `WRAP_ETH` or V4 swap step that names an input token otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap
or amount. A V4 step with a swap action sets the input side when no earlier step that names an input token or amount. A V4 step with a swap action sets
step set it, even when it names neither (see Display Consistency). That step the input side when no earlier step set it, even when it names neither (see
counts its own amount, whoever pays it: a V2 or V3 exact-out step its Display Consistency). A V2 exact-out step gives its `amountInMax`, and a V4
`amountInMax`, and a V4 step what it takes of its input token from the user's swap step the `amountIn` of the swap action that spends its input currency.
wallet, as below, or, when the user pays it none of that token, the `amountIn` Each later V2 or V3 exact-in or V2 exact-out step whose `payerIsUser` is true
of the swap action that spends it. Each later V2 or V3 exact-in or exact-out also takes its amount, found in the same way, from the user's wallet. So does
step whose `payerIsUser` is true takes its amount, found in the same way, from every V4 step but the one that sets the input side, with a swap action or
the user's wallet. Every V4 step, with a swap action or without, takes from without: it takes what its `SETTLE`s paid by the user (`payerIsUser` true) and
the user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own. A `SETTLE` of any amount but
its `SETTLE_ALL`s pay, for each token on its own; what the step that sets the `OPEN_DELTA` pays that amount, and a `SETTLE` of `OPEN_DELTA` or a
input side takes of any other token counts as a later step's take. A `SETTLE` `SETTLE_ALL` pays what the step owes, as above. What a step takes of
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of `Token In` is added to the line's, unless a `PERMIT2_PERMIT` set the input
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step side, whose amount the line keeps. A permit whose spender is the router and
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the which the router does not let fail caps what the router may take of its token
rest taken to another address, reads `5000.0000 USDT`. A later step paid from after it; any other permit caps nothing the router takes. So a V3 exact-in of
the router spends what the router holds and takes nothing, and so does a V2 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is `1000.0000 USDT`. When either amount is not a quantity (a V4 open delta,
added to the total while both are quantities: a V3 exact-in of 600 USDT and a `ALREADY_PAID`, or an amount the decoder does not know), the total is not
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When known and there is no `Amount` line. A step paid from the router spends what
the decoder does not know a take's amount, the total is not known and there is the router holds and adds nothing, and so does a V2 exact-in whose `amountIn`
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, is `ALREADY_PAID`, which takes nothing from the user. What a step takes of a
`Unlimited` included, stands as the `Amount` figure in place of the total, token other than `Token In`, or of `Token In` when the decoder does not know
which it does only in the plain shape of Uniswap's own routes: it comes before the amount or when a permit that caps nothing set the input side, gives three
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the lines after the `Amount` line (after `Token In` when there is none), in step
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, order: `Moved from the sender`, its amount, worded as the `Amount` line words
the router may not let it fail, no step before it takes its token, a one, or `Unknown (could not be decoded)` when the decoder does not know it;
`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,
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 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 `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. A step may also take
does not know gives the `Moved from the sender` line alone. A step may also from the user's wallet what the decoder did not read: a V4 step, other than
take from the user's wallet what the decoder did not read: a V4 step whose the one that sets the input side, whose input or one of whose `SETTLE`s or
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3 exact-in
router still runs; a V2 or V3 exact-in or exact-out step or a or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it cannot read;
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one
path is shorter than one token address or has bytes after its last hop; every `Moved from the sender` line, among those lines in step order, reading
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands; `Unknown (could not be decoded)`, and there is then no `Amount` line, whose
and every step it fails on. Each such step gives one `Moved from the sender` figure might be too low. Then each `PERMIT2_TRANSFER_FROM` but one into the
line among those, reading `Unknown (could not be decoded)`. That line, or any router, in step order, gives three lines: `Moved from the sender`, the amount
line for a token the decoder does not know, leaves no `Amount` line, whose it moves, for example `1000000.0000 USDT`; `Token moved`, giving the token's
figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the address as the `Token In` line does; and `Moved to`, which reads
permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into
the router, in step order, gives three lines: `Moved from the sender`, the
amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the
token's address as the `Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and `Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose where it goes, and so its amount is shown once. These lines change neither the
amount does not stand as the `Amount` figure, before the step that sets the `Paid to` lines nor the minimum's label. A step goes unread when its recipient
input side or after it, gives three lines, in step order, after those for each does not decode as an address, or when it is a command the decoder does not
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
giving the token's address as the `Token In` line does; and `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`Allowance spender`, the address it lets take the token. Only a permit whose `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
amount stands gives none: the `Amount` line gives its amount, `Token In` its (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH` the NFT commands among them), or a type its release names no command for.
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`,
`WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
### Non-Goals Forever ### Non-Goals Forever
-5
View File
@@ -13,11 +13,6 @@
"fmt": "prettier --write .", "fmt": "prettier --write .",
"fmt-check": "prettier --check ." "fmt-check": "prettier --check ."
}, },
"jest": {
"setupFiles": [
"<rootDir>/tests/support/navigatorLocks.js"
]
},
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@tailwindcss/cli": "4.3.1", "@tailwindcss/cli": "4.3.1",
-8
View File
@@ -1379,13 +1379,6 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW. // balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a // Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip. // wallet or address added, renamed or deleted during the round trip.
//
// Nothing is applied when the network stored NOW is not the one the refresh
// ran on. A network switch during the round trip cleared every address's
// balances (applyChainSwitchFields()), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network.
async function backgroundRefresh() { async function backgroundRefresh() {
const s = await getState(); const s = await getState();
const now = Date.now(); const now = Date.now();
@@ -1409,7 +1402,6 @@ async function backgroundRefresh() {
} }
await updateState((fresh) => { await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) { for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) { for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase()); const got = refreshed.get(String(addr.address).toLowerCase());
+26 -19
View File
@@ -16,20 +16,17 @@
// across any number of awaits and no concurrent work can // across any number of awaits and no concurrent work can
// move it. // move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it // updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored // back — all inside a queue, so two background writes
// record takes, so no other write lands in between, by // never interleave, and the read is one storage round trip
// the background or by any page except a page in a // ahead of the write rather than a page lifetime ahead of
// Firefox private window (README, "Stored state and its // it (which is what made the popup's saveState() need a
// version"). The read is one storage round trip ahead of
// the write rather than a page lifetime ahead of it
// (which is what made the popup's saveState() need a
// per-field merge against a baseline at all). // per-field merge against a baseline at all).
// //
// A handler that must both read and write therefore does its network work // A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState(). // against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding. // It never publishes an object other in-flight work is holding.
const { storageGet, storageSet, withLock } = require("../shared/browserApi"); const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState"); const { normalizePersisted } = require("../shared/persistedState");
const { const {
STATE_SCHEMA_VERSION, STATE_SCHEMA_VERSION,
@@ -54,6 +51,11 @@ async function getState() {
return normalizePersisted(result.autistmask); return normalizePersisted(result.autistmask);
} }
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) { async function updateStateOnce(mutate) {
const s = await getState(); const s = await getState();
await mutate(s); await mutate(s);
@@ -68,22 +70,27 @@ async function updateStateOnce(mutate) {
// Apply `mutate` to a record read fresh from storage and write the result // Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in // back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow. The write is the // place; it may be async, but it must not do anything slow — the window
// WHOLE record, which is safe only because the lock (withLock() in // between the read and the write is the window in which another context's
// src/shared/browserApi.js) is held from the read to the write: two // write is lost, and keeping it to one storage round trip is what makes a
// background turns, or a turn and a page's save, never interleave // whole-record write safe here. Concretely: the write is the WHOLE record, so
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits // a popup write that lands inside that window is reverted, in every field, by
// while it is held, except a page in a Firefox private window, which Firefox // the record this turn read before it. That is accepted because the window is
// gives locks of its own (README, "Stored state and its version"). // one round trip long and the popup is not writing while the worker is;
// widening it is what would make it a real hazard.
// //
// `mutate` must also not call updateState() itself, directly or through // `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds, // anything it awaits: the queue is strictly serial, so the inner turn waits on
// and the outer one is waiting on the inner one. That deadlocks every write of // the outer one, which is waiting on the inner one. That deadlocks the whole
// the stored record, not just the caller. Mutate the record you were handed. // background, not just the caller. Mutate the record you were handed.
// //
// Resolves with the record that was written. // Resolves with the record that was written.
function updateState(mutate) { function updateState(mutate) {
return withLock("autistmask", () => updateStateOnce(mutate)); const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
} }
module.exports = { getState, updateState }; module.exports = { getState, updateState };
+2 -2
View File
@@ -90,7 +90,7 @@
<div id="add-wallet-section-mnemonic"> <div id="add-wallet-section-mnemonic">
<!-- The die button shares the instruction's row: on a row <!-- The die button shares the instruction's row: on a row
of its own it pushes Import below the fold at of its own it pushes Import below the fold at
396x600. --> 360x600. -->
<div class="mb-2 flex justify-between items-start gap-2"> <div class="mb-2 flex justify-between items-start gap-2">
<p> <p>
Enter your 12 or 24 word recovery phrase below, or Enter your 12 or 24 word recovery phrase below, or
@@ -212,7 +212,7 @@
/> />
</div> </div>
<!-- The error line sits beside Import, not above it: at <!-- The error line sits beside Import, not above it: at
396x600 the button already starts near the bottom of 360x600 the button already starts near the bottom of
the popup, and a line of its own would push it below the popup, and a line of its own would push it below
the fold. The longest error fits on one line here. --> the fold. The longest error fits on one line here. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
+5 -101
View File
@@ -5,14 +5,12 @@ const {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
} = require("../shared/state"); } = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log"); const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const { const {
$, $,
showView, showView,
@@ -24,7 +22,6 @@ const {
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const { const {
onStorageChanged,
tabsGetCurrent, tabsGetCurrent,
tabsGetZoom, tabsGetZoom,
windowsGetCurrent, windowsGetCurrent,
@@ -71,83 +68,24 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
let refreshTimer = null; let refreshTimer = null;
// The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// reaches this page only after the stored record (followNetworkSwitch() below).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is.
//
// 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() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
try { 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([ await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances( refreshBalances(
wallets, state.wallets,
state.rpcUrl, state.rpcUrl,
state.blockscoutUrl, state.blockscoutUrl,
state.trackedTokens, state.trackedTokens,
networkId, state.networkId,
pageClosed.signal, pageClosed.signal,
), ),
]); ]);
const refreshed = new Map(); state.lastBalanceRefresh = Date.now();
let allTokenBalancesFetched = true; await saveState();
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(); renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} catch (e) { } catch (e) {
// Every call site fires this and walks away — the boot below, the ten // Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never // second interval, and eight views through ctx — so it must never
@@ -178,7 +116,6 @@ const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
startRefresh, startRefresh,
restoreView,
pageClosed: pageClosed.signal, pageClosed: pageClosed.signal,
showAddWalletView: () => { showAddWalletView: () => {
pushCurrentView(); pushCurrentView();
@@ -246,28 +183,6 @@ function fallbackView() {
showView("main"); showView("main");
} }
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
async function followNetworkSwitch() {
let switched = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId === state.networkId) return false;
settings.cutViewStack();
takeStoredNetwork(stored);
clearPopupCaches();
switched = true;
}).catch(() => {});
if (switched) settings.showNewNetwork();
}
// The background opens the approval window as wide as the page, but the // The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than // browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is // the window takes a vertical scrollbar out of it too. So once the screen is
@@ -305,7 +220,7 @@ async function widenApprovalWindow() {
async function init() { async function init() {
// First, before anything can save: showView() saves on every navigation // First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be // without awaiting, so a save that fails from here on has somewhere to be
// reported rather than reaching only the console // reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of // (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well. // the approval-window branch below too, since that window saves as well.
// //
@@ -412,17 +327,6 @@ async function init() {
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.init(ctx); deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the
// one this page's own switch in Settings saves names the network it
// already has, and a record naming none is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch();
});
// A switch saved since loadState() above, before there was a listener.
followNetworkSwitch();
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");
} else { } else {
+5 -5
View File
@@ -39,8 +39,8 @@ function markViewRendered(view) {
} }
// Begin a fresh page-load scope. The popup gets one by being loaded, and // Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one after a network switch, because every screen then on the page // asks for one when the user switches the network, because every screen
// shows the previous network (showNewNetwork() in views/settings.js); the // then on the page shows the previous network (views/settings.js); the
// unit tests, which simulate several page loads against one module // unit tests, which simulate several page loads against one module
// instance, ask for one too. // instance, ask for one too.
function resetRenderedViews() { function resetRenderedViews() {
@@ -145,15 +145,15 @@ function isRenderablePendingTx(value) {
// The stored transaction transactionDetail.render() shows. contractAddress is // The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there. // optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open and whose // `chainId` picks the network whose explorer the links open, so when it is
// Blockscout the transaction is read from, so it has to name one. // there it has to name one.
function isRenderableTx(value) { function isRenderableTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress)) && (!value.contractAddress || isAddressText(value.contractAddress)) &&
SUPPORTED_CHAIN_IDS.has(value.chainId) (!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
); );
} }
+2 -9
View File
@@ -1,11 +1,4 @@
const { const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -79,7 +72,7 @@ function init(ctx) {
return; return;
} }
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
trackToken({ state.trackedTokens.push({
address: contractAddr, address: contractAddr,
symbol: info.symbol, symbol: info.symbol,
decimals: info.decimals, decimals: info.decimals,
+8 -17
View File
@@ -65,25 +65,11 @@ function show() {
}); });
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden"); 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( $("address-balances").innerHTML = balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
@@ -97,6 +83,11 @@ function renderBalances() {
ctx.showAddressToken(); ctx.showAddressToken();
}); });
}); });
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
} }
let loadedTxs = []; let loadedTxs = [];
@@ -289,4 +280,4 @@ function init(_ctx) {
exportPrivkey.init(); exportPrivkey.init();
} }
module.exports = { init, show, renderBalances }; module.exports = { init, show };
+32 -45
View File
@@ -48,18 +48,37 @@ function show() {
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress; const ai = state.selectedAddress;
const tokenId = state.selectedToken; const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
currentSymbol = if (tokenId === "ETH") {
tokenId === "ETH" symbol = nativeCurrency();
? nativeCurrency() amount = parseFloat(addr.balance || "0");
: resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens); 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;
$("address-token-title").textContent = $("address-token-title").textContent =
wallet.name + wallet.name +
" \u2014 Address " + " \u2014 Address " +
(ai + 1) + (ai + 1) +
" \u2014 " + " \u2014 " +
displaySymbol(currentSymbol); displaySymbol(symbol);
// Blockie // Blockie
const blockieEl = $("address-token-jazzicon"); const blockieEl = $("address-token-jazzicon");
@@ -81,7 +100,13 @@ function show() {
$("address-token-line").dataset.full = addr.address; $("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line")); attachCopyHandlers($("address-token-line"));
renderBalance(); // 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);
// Token contract details (ERC-20 only) // Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info"); const contractInfo = $("address-token-contract-info");
@@ -143,44 +168,6 @@ function show() {
loadTransactions(addr.address, tokenId); 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) { async function loadTransactions(address, tokenId) {
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
@@ -356,4 +343,4 @@ function init(_ctx) {
}); });
} }
module.exports = { init, show, renderBalance }; module.exports = { init, show };
+25 -40
View File
@@ -11,7 +11,6 @@ const {
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel, tokenLabel,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { const {
@@ -58,15 +57,9 @@ const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
// The link opens the explorer of the network with `chainId`, or of the active function approvalAddressHtml(address) {
// network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link.
function approvalAddressHtml(address, chainId) {
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
if (chainId && !networkByChainId(chainId)) { return renderAddressHtml(address, { title });
return renderAddressHtml(address, { title, noLink: true });
}
return renderAddressHtml(address, { title, chainId });
} }
// The amount line for a decoded ERC-20 call. With a known scale it is the // The amount line for a decoded ERC-20 call. With a known scale it is the
@@ -92,12 +85,13 @@ function tokenAmountText(rawAmount, decimals, symbol) {
function decodeCalldata(data, toAddress, chainId, fromAddress) { function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null; if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below: // Where a token's scale is looked for, for every decoder below: the ERC-20
// what the wallet knows of the tokens on the network with `chainId`, which // amount line and the swap's Amount and Min. received lines resolve it the
// need not be the active one. The ERC-20 amount line and the swap's Amount // same way, and refuse to format the same way when it is nowhere.
// and Min. received lines resolve the scale the same way, and refuse to const decimalsSources = {
// format the same way when it is nowhere. trackedTokens: state.trackedTokens,
const decimalsSources = tokenSourcesByChainId(chainId); wallets: state.wallets,
};
// Try ERC-20 (approve / transfer) // Try ERC-20 (approve / transfer)
try { try {
@@ -231,11 +225,10 @@ function showTxFee(approvedTx) {
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same // Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. In the native currency of the network shown // fee reads the same on both. In the native currency of the network shown
// above, and at the price on it, as the value is. // above, as the value is.
$("approve-tx-fee").textContent = formatFee( $("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas, gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId), nativeCurrencyByChainId(approvedTx.chainId),
approvedTx.chainId,
); );
let detail = let detail =
@@ -268,7 +261,10 @@ function showTxApproval(details) {
const toAddr = approvedTx.to; const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(approvedTx.value || "0");
const sources = tokenSourcesByChainId(approvedTx.chainId); const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
@@ -345,35 +341,27 @@ function showTxApproval(details) {
}; };
} }
// Every address link on this screen opens the explorer of the network the
// transaction is for, which the Network line names, whichever network is
// active.
$("approve-tx-origin").textContent = details.origin; $("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml( $("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
details.approvedFrom,
approvedTx.chainId,
);
// Show token symbol next to contract address if known on the network the // Show token symbol next to contract address if known
// transaction is for const symbol = toAddr ? tokenLabel(toAddr) : null;
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
if (toAddr) { if (toAddr) {
let toHtml = ""; let toHtml = "";
if (symbol) { if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`; toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
} }
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId); toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
} }
const ethValueFormatted = formatTxValue(ethValue); const ethValueFormatted = formatTxValue(
// Priced from the exact value, not the four-decimal figure, at the price formatEther(approvedTx.value || "0"),
// on the network the transaction is for, as the confirmation screen prices );
// its amount, so the same value reads the same on both. const ethPrice = getPrice("ETH");
const ethPrice = getPrice("ETH", approvedTx.chainId); const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const ethUsd = ethPrice ? parseFloat(ethValue) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the // In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: the active network can // Network line names, not the active network's: the active network can
@@ -400,12 +388,9 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
} }
detailsHtml += approvalAddressHtml( detailsHtml += approvalAddressHtml(d.address);
d.address,
approvedTx.chainId,
);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
+23 -67
View File
@@ -1,7 +1,4 @@
// Helpers the views in this directory share: showing a view and going back, // Shared DOM helpers used by all views.
// error and flash lines and the warning banners, formatting of amounts, fees,
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
// of the network a chain id names, explorer links, and click-to-copy.
// //
// Escaping rule for every view in this directory, since they all build // Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string // markup by concatenation: any VALUE interpolated into an innerHTML string
@@ -300,30 +297,15 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// What the wallet knows of the tokens on the network with this chain id, or // The symbol shown for a token line, resolved from the bundled list, the
// on the active network when no chain id is given, as the `sources` that // tokens the user tracks, and the explorer's report — the same chain the
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address // amount line's scale comes from. Null when no source names one, so the token
// names a token on one network only, and every token balance held is the // lines keep saying `Unknown token` for a token nothing knows.
// active network's, so another network gets the tokens tracked on it function tokenLabel(address) {
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances. return resolveTokenSymbol(address, {
function tokenSourcesByChainId(chainId) { trackedTokens: state.trackedTokens,
const net = chainId ? networkByChainId(chainId) : currentNetwork(); wallets: state.wallets,
if (net && net.id === state.networkId) { });
return { trackedTokens: state.trackedTokens, wallets: state.wallets };
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// The symbol shown for a token line on the network with `chainId`, or on the
// active network when none is given: from the bundled list, the tokens the
// user tracks, and the explorer's report, the sources the amount line's scale
// comes from. Null when no source names one, so the token lines keep saying
// `Unknown token` for a token nothing knows.
function tokenLabel(address, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
} }
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
@@ -380,18 +362,6 @@ 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) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
nativeCurrency(), nativeCurrency(),
@@ -421,13 +391,12 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
} }
if (showZero && trackedTokens) { if (showZero && trackedTokens) {
for (const t of trackedTokens) { for (const t of trackedTokens) {
const id = t.address.toLowerCase(); if (seen.has(t.address.toLowerCase())) continue;
if (seen.has(id)) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
state.tokensNotFetched.includes(id) ? null : 0, 0,
getPrice(t.symbol), getPrice(t.symbol),
id, t.address.toLowerCase(),
); );
} }
} }
@@ -639,28 +608,18 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// The RPC and Blockscout URLs Settings has for the network with this chain id, // The node of the network a transaction was reviewed for, the one its chain id
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail // names, which need not be the active one: the popup can reopen onto ConfirmTx
// use the network their transaction's chain id names, and the popup can reopen // or WaitTx after Settings switched the network. Its RPC URL is the one
// onto them after Settings switched the network. The active network's are // Settings has for that network: the active network's is state.rpcUrl, any
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it, // other's the one remembered for it (see applyChainSwitchFields()).
// or its defaults (see applyChainSwitchFields()).
function endpointsByChainId(chainId) {
const net = networkByChainId(chainId);
if (net.id === state.networkId) {
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
}
const remembered = state.networkEndpoints[net.id] || {};
return {
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
};
}
// The node of the network a transaction was reviewed for.
function reviewedNetworkProvider(txInfo) { function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId); const net = networkByChainId(txInfo.chainId);
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id); if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
} }
// Block-explorer URLs. The origin is a per-network constant from // Block-explorer URLs. The origin is a per-network constant from
@@ -799,12 +758,10 @@ module.exports = {
flashCopyFeedback, flashCopyFeedback,
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
trackToken,
addressHoldsFunds, addressHoldsFunds,
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenSourcesByChainId,
tokenLabel, tokenLabel,
formatFee, formatFee,
addressDotHtml, addressDotHtml,
@@ -820,7 +777,6 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId,
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
+28 -61
View File
@@ -38,8 +38,6 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi"); const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
@@ -245,51 +243,7 @@ async function renderSiteLists() {
); );
} }
// A network switch, made by the user here or saved by the background once the function init(ctx) {
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. These two leave the page as README's
// Screen Map describes, the first before the switch is saved, so that save
// keeps the cut for a popup closed straight after, the second once `state`
// holds the new network.
//
// The stack is cut at the first screen that cannot be rendered from saved
// state, such as Send or AddToken, and at AddressToken unless it shows the
// native token: any other token there was picked on the previous network, so
// it is no longer selected either. Without a wallet the stack starts at
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
function cutViewStack() {
if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1) {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.selectedToken = null;
}
state.viewStack = restorableStack(state.viewStack, state.currentView);
}
// Every screen counts as not yet rendered, so "Back" renders the one it lands
// on for the new network. Settings on top is brought up to date in place;
// any other screen is drawn again as a reopened popup draws it, which is Home
// for one that cannot be rendered from saved state. Without a wallet any other
// screen is Welcome or AddWallet, which show nothing from a network.
function showNewNetwork() {
resetRenderedViews();
if (state.currentView === "settings") {
markViewRendered("settings");
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
} else if (state.hasWallet) {
ctx.restoreView();
}
updateDebugBanner(state.currentView);
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx); deleteWallet.init(ctx);
showPhrase.init(); showPhrase.init();
@@ -365,18 +319,37 @@ function init(_ctx) {
const networkSelect = $("settings-network"); const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value; const newId = networkSelect.value;
cutViewStack(); if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved // Open pages are told by the background, as after a site's approved
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
showNewNetwork(); resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
@@ -511,10 +484,4 @@ function init(_ctx) {
}); });
} }
module.exports = { module.exports = { init, show, renderSiteLists };
init,
show,
renderSiteLists,
cutViewStack,
showNewNetwork,
};
+4 -11
View File
@@ -1,11 +1,4 @@
const { const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -50,7 +43,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10), decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol, name: btn.dataset.name || btn.dataset.symbol,
}; };
trackToken(token); state.trackedTokens.push(token);
await saveState(); await saveState();
showFlash("Added " + token.symbol); showFlash("Added " + token.symbol);
renderTop10(); renderTop10();
@@ -117,7 +110,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10), decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol, name: opt.dataset.name || opt.dataset.symbol,
}; };
trackToken(token); state.trackedTokens.push(token);
await saveState(); await saveState();
showFlash("Added " + token.symbol); showFlash("Added " + token.symbol);
renderTop10(); renderTop10();
@@ -163,7 +156,7 @@ function init(_ctx) {
return; return;
} }
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
trackToken({ state.trackedTokens.push({
address: addr, address: addr,
symbol: info.symbol, symbol: info.symbol,
decimals: info.decimals, decimals: info.decimals,
+2 -10
View File
@@ -24,11 +24,7 @@
// than written again locally so that tests/popupElementIds.test.js sees these // than written again locally so that tests/popupElementIds.test.js sees these
// lookups and holds every id below against the markup. // lookups and holds every id below against the markup.
const { $, VIEWS } = require("./helpers"); const { $, VIEWS } = require("./helpers");
const { const { storageGet, storageRemove } = require("../../shared/browserApi");
storageGet,
storageRemove,
withLock,
} = require("../../shared/browserApi");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Typed in full before anything is erased, in the same spirit as the wallet // Typed in full before anything is erased, in the same spirit as the wallet
@@ -141,11 +137,7 @@ async function resetProfile() {
return; return;
} }
try { try {
// Under the lock every write of the stored record takes, so the erase await storageRemove("autistmask");
// cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask"));
} catch (e) { } catch (e) {
log.errorf("state recovery: reset failed:", e); log.errorf("state recovery: reset failed:", e);
setFlash("The saved data could not be erased. Nothing was changed."); setFlash("The saved data could not be erased. Nothing was changed.");
+8 -11
View File
@@ -18,11 +18,10 @@ const {
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId,
displaySymbol, displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -85,10 +84,9 @@ function show(tx) {
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's, // the fee are in its native currency, not the active network's,
// the links open its explorer, and the block, network details and // and the links open its explorer: the active network can change,
// calldata are read from its Blockscout: the active network can // in Settings or when the user approves a site's request, before
// change, in Settings or when the user approves a site's request, // a later popup shows this screen again.
// before a later popup shows this screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
@@ -313,9 +311,8 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return; if (!section || !actionEl || !detailsEl) return;
try { try {
const { blockscoutUrl } = endpointsByChainId(chainId);
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/transactions/" + txHash, state.blockscoutUrl + "/transactions/" + txHash,
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
@@ -329,12 +326,12 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token // explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract. // transfer that is the transfer's recipient, not the token contract.
// On the network the transaction was sent on, whose Blockscout was // On the active network, the one whose explorer was just asked, and
// just asked, and from the sender it reports. // from the sender it reports.
const decoded = decodeCalldata( const decoded = decodeCalldata(
inputData, inputData,
txData.to?.hash || "", txData.to?.hash || "",
chainId, currentNetwork().chainId,
txData.from?.hash || "", txData.from?.hash || "",
); );
if (decoded) { if (decoded) {
+1 -1
View File
@@ -280,7 +280,7 @@ function decodedDetailsHtml(decoded, chainId) {
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`; html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
const sym = tokenLabel(d.address, chainId) || "Unknown token"; const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`; html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address, chainId);
} else { } else {
-47
View File
@@ -31,11 +31,6 @@
// Everything is resolved on use rather than captured at module load. The MV3 // Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit // service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require(). // suite installs its stubs on `global.chrome` around a require().
//
// The module also holds withLock(), a wrapper around the browser's
// `navigator.locks`, which is in neither namespace. It sits beside
// storageGet() and storageSet() because every write of the stored record
// takes it.
// The extension API namespace, preferring `browser.*` where it exists. // The extension API namespace, preferring `browser.*` where it exists.
// //
@@ -224,46 +219,6 @@ function storageRemove(keys) {
return Promise.resolve(storage.remove(keys)); return Promise.resolve(storage.remove(keys));
} }
/**
* Call `listener` after every write to `storage.local`, whether this page,
* another page or the background made it, with what the write changed, as the
* browser reports it: `{ [key]: { oldValue, newValue } }`.
*
* @param {function(Object): void} listener
*/
function onStorageChanged(listener) {
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
if (areaName === "local") listener(changes);
});
}
/**
* Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
* and its background share these locks, except a page in a Firefox private
* window (README, "Stored state and its version"), so while one of them holds
* `name` another asking for it waits, in the order they asked. The lock is let
* go when `fn` settles, whether it resolved or rejected.
*
* Every write of the stored record holds the lock named after its key,
* "autistmask", from its read to its write: saveState() in
* src/shared/state.js, updateState() in src/background/state.js, and the
* reset in src/popup/views/stateRecovery.js.
*
* @param {string} name
* @param {function(): Promise<*>} fn
* @returns {Promise<*>} what `fn` resolved with.
* @throws rejects, without running `fn`, where `navigator.locks` is absent.
*/
function withLock(name, fn) {
if (typeof navigator === "undefined" || !navigator.locks) {
return Promise.reject(
new Error("navigator.locks is not available in this context"),
);
}
return navigator.locks.request(name, () => fn());
}
// `storage.session`, the browser's session storage: what is kept there lasts // `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped // until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background // and started again, which module-level variables do not (README, Background
@@ -398,7 +353,6 @@ module.exports = {
getPlatformInfo, getPlatformInfo,
hasBrowserNamespace, hasBrowserNamespace,
notify, notify,
onStorageChanged,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
sessionStorageGet, sessionStorageGet,
@@ -419,5 +373,4 @@ module.exports = {
windowsGetLastFocused, windowsGetLastFocused,
windowsRemove, windowsRemove,
windowsUpdate, windowsUpdate,
withLock,
}; };
+17 -37
View File
@@ -1,18 +1,8 @@
// The popup's network switch. // Consolidated chain-switch handler for the popup.
// //
// A network switch reaches the popup's `state` in one of two ways: // Every state change required when the active network changes is
// // performed here so that callers (settings UI, future chain additions) all go
// - onChainSwitch() below makes the switch the user picks in Settings, with // through a single code path.
// applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js).
//
// Both change `state` inside a save, holding the lock, and both reset the
// popup's own caches through clearPopupCaches(), so a reset added there
// reaches both. A field applyChainSwitchFields() sets has to be copied by
// copyChainSwitchFields() too.
// //
// Adding a new chain (e.g. ETC) requires only a new entry in // Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed. // networks.js — no per-caller wiring is needed.
@@ -30,32 +20,22 @@
const { applyChainSwitchFields } = require("./chainSwitchFields"); const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices"); const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state. // Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain, or null when // Returns the network configuration object for the new chain.
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followNetworkSwitch() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
let net = null;
await saveState(() => { const net = applyChainSwitchFields(state, newNetworkId);
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); // --- price cache ---
clearPopupCaches(); // Prices are chain-specific (testnet tokens are worthless,
}); // ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
return net; return net;
} }
module.exports = { onChainSwitch, clearPopupCaches }; module.exports = { onChainSwitch };
+2 -31
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of // the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js). // its own read-modify-write (src/background/state.js).
// //
// Everything here is synchronous and touches nothing but the objects it is // Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is // given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage // what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back. // microseconds earlier and is about to be written back.
@@ -76,33 +76,4 @@ function applyChainSwitchFields(s, newNetworkId) {
return net; return net;
} }
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it: module.exports = { applyChainSwitchFields };
// the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in
// src/shared/state.js). Balances are taken by address, and an address
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens;
s.trackedTokens = switched.trackedTokens;
s.networkId = switched.networkId;
s.rpcUrl = switched.rpcUrl;
s.blockscoutUrl = switched.blockscoutUrl;
s.lastBalanceRefresh = switched.lastBalanceRefresh;
s.tokenHolderCache = switched.tokenHolderCache;
s.fraudContracts = switched.fraudContracts;
const stored = new Map();
for (const wallet of switched.wallets) {
for (const addr of wallet.addresses) stored.set(addr.address, addr);
}
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
}
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
-9
View File
@@ -28,13 +28,6 @@ const DEFAULT_STATE = {
// others are restored from when the active network changes. See // others are restored from when the active network changes. See
// applyChainSwitchFields(). // applyChainSwitchFields().
networkTrackedTokens: {}, 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", networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL, rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -419,8 +412,6 @@ function normalizePersisted(saved) {
// so a stored string walks through the `|| []` and a stored number walks // so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray(). // through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts); out.fraudContracts = textList(saved.fraudContracts);
// Contract addresses too, read with `includes()` and `filter()`.
out.tokensNotFetched = textList(saved.tokensNotFetched);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {}); out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system"; out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false; out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
+2 -3
View File
@@ -38,12 +38,11 @@ function clearPrices() {
} }
// Return the USD price for a symbol on the network with `chainId`, or on the // Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet, on a chain id // active network when no chain id is given; null on testnet / unknown.
// that names no network, or for a symbol with no price.
function getPrice(symbol, chainId) { function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork(); const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (!network || network.isTestnet) return null; if (network.isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
+37 -56
View File
@@ -23,7 +23,6 @@
// guarantee. // guarantee.
const { networkById } = require("./networks"); const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const { const {
DEFAULT_STATE, DEFAULT_STATE,
PERSISTED_FIELDS, PERSISTED_FIELDS,
@@ -36,7 +35,7 @@ const {
migrationNeeded, migrationNeeded,
} = require("./stateSchema"); } = require("./stateSchema");
const { storageGet, storageSet, withLock } = require("./browserApi"); const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log"); const { log } = require("./log");
// The live record the proxy below guards. Everything inside this module reads // The live record the proxy below guards. Everything inside this module reads
@@ -126,10 +125,8 @@ function currentNetwork() {
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network // finished, or when its last successful saveState() began. saveState() diffs
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // the live state against this to find only the fields THIS page changed since.
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -461,18 +458,20 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// This page's own live `state` is deliberately NOT rehydrated from a field // This page's own live `state` is deliberately NOT rehydrated from a field
// another page changed — only the record written to storage is merged. // another page changed — only the record written to storage is merged.
// showView() fires saveState() on every navigation without awaiting it, // showView() fires saveState() on every navigation without awaiting it,
// which is why saves from one page wait for each other (see saveState()), and // which is what makes the queue above necessary in the first place, and a
// a save that is slow to come back has no way to tell whether the field it // save that is slow to come back has no way to tell whether the field it
// is about to hand back is still the current answer or has since been // is about to hand back is still the current answer or has since been
// overtaken by something this very page did in the meantime; writing it // overtaken by something this very page did in the meantime; writing it
// into `state` regardless reintroduced exactly the clobber this function // into `state` regardless reintroduced exactly the clobber this function
// exists to remove, just delayed and confined to one page instead of two // exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it // (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. The // before this fix; only the persisted record is guaranteed current.
// network is the exception: the popup takes a switch another context saved async function saveStateOnce() {
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js). // A copy, so what this save compares and writes is the page's state as it
async function saveStateOnce(beforeWrite) { // stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -486,12 +485,6 @@ async function saveStateOnce(beforeWrite) {
// come from storage in its loaded (self-healed) shape. See // come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js. // normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask); const fresh = normalizePersisted(result.autistmask);
if (beforeWrite && beforeWrite(fresh) === false) return;
// A copy, so what this save compares and writes is the page's state as it
// stood once the stored record was read. A change made while it waits on
// the write is left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const merged = { ...fresh }; const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) { for (const key of PERSISTED_FIELDS) {
@@ -536,21 +529,35 @@ async function saveStateOnce(beforeWrite) {
rawState.hasWallet = rawState.wallets.length > 0; rawState.hasWallet = rawState.wallets.length > 0;
// What this save compared and wrote, not the page's state now: a change // What this save compared and wrote, not the page's state now: a change
// made during the save must still differ from the baseline, or the next // made during the save must still differ from the baseline, or the save
// save finds nothing to store // queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448). // (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current; baseline = current;
} }
// showView() calls saveState() on every navigation without awaiting it, so
// two saves from the SAME page can be in flight at once — e.g. a screen
// shown, then immediately replaced before the first save's storageGet()
// round trip has come back. Left concurrent, the first save's turn would
// finish after the second's live-state mutation and then re-hydrate `state`
// from what IT read, stomping the second, later change back to a stale
// value — the same clobber this function exists to prevent, just between
// two saves on one page instead of two pages. Queuing makes every save's
// snapshot-diff-write-rehydrate run start to finish before the next one
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen // Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js). // to say it on (src/popup/index.js).
// //
// A save that fails must not fail silently. showView() fires saveState() on // A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and `await saveState()` inside an // every navigation without awaiting it, and the queue below has to attach a
// unguarded event handler is no better: either way, without this, a failure // rejection handler to keep advancing — so a failing save was swallowed
// would reach only the console, where the user never looks, and the wallet // entirely: no throw, no message, nothing on screen. The wallet kept running
// would keep running against storage that rejects every write. That is the // against storage that was rejecting every write, which is the data-loss half
// data-loss half of https://git.eeqj.de/sneak/AutistMask/issues/362. // of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
// //
// This is the "tell the user" half; the other half is the floor in // This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in // normalizePersisted(), which stops the malformed-record cause from arising in
@@ -576,25 +583,11 @@ function reportSaveFailure(err) {
} }
} }
// Each save runs start to finish holding the lock every write of the stored function saveState() {
// record takes (withLock() in src/shared/browserApi.js), so no write by const turn = saveQueue.then(saveStateOnce);
// another page or the background lands between its read and its write and is // The queue must advance even when a save rejects, or every save after
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox // it queues behind a promise that never settles.
// private window is the exception: Firefox gives it locks of its own (README, saveQueue = turn.catch(() => {});
// "Stored state and its version").
//
// 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 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 // Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await // one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling. // keeps its own error handling.
@@ -602,17 +595,6 @@ function saveState(beforeWrite) {
return turn; return turn;
} }
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followNetworkSwitch() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -658,7 +640,6 @@ module.exports = {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+3 -10
View File
@@ -20,7 +20,6 @@ const { toDecimals } = require("./transferAmount");
// truncation without the approval screens' nonzero floor: the transaction // truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision. // detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay"); const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // over the address, not part of its identity. Every address comparison in
@@ -52,6 +51,7 @@ function parseTx(tx, addrLower, chainId) {
} }
// Map known DEX methods to "Swap" for cleaner display // Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([ const SWAP_METHODS = new Set([
"execute",
"swap", "swap",
"swapExactTokensForTokens", "swapExactTokensForTokens",
"swapTokensForExactTokens", "swapTokensForExactTokens",
@@ -59,16 +59,9 @@ function parseTx(tx, addrLower, chainId) {
"swapTokensForExactETH", "swapTokensForExactETH",
"swapExactTokensForETH", "swapExactTokensForETH",
"swapETHForExactTokens", "swapETHForExactTokens",
"multicall",
]); ]);
// Multisigs, smart accounts and many other contracts also have an const label = SWAP_METHODS.has(method)
// `execute` or `multicall` method, so either is a swap only when sent
// to a Universal Router on this transaction's network: the same
// addresses the approval screen decodes a swap for.
const isSwap =
SWAP_METHODS.has(method) ||
((method === "execute" || method === "multicall") &&
routerRelease(to, chainId) !== null);
const label = isSwap
? "Swap" ? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1); : method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract"; direction = "contract";
+134 -565
View File
File diff suppressed because it is too large Load Diff
-273
View File
@@ -1,273 +0,0 @@
// 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");
});
-270
View File
@@ -1,270 +0,0 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active.
//
// The screen named the token, and scaled its amount, from the active network's
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
// an unknown token with its amount in base units, and the screens after it
// labelled the amount `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
// It now uses what the wallet knows of the tokens on the network the
// transaction is for.
//
// Its address links opened the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
// explorer of the network the transaction is for.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Ethereum Mainnet active and FROM holding
// `tokenBalances` there, for the site's transaction sending TRANSFER_DATA to
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
// the signed transaction with a failure, which leads to the error screen.
async function showTokenTransfer(
tokenBalances = [],
chainId = NETWORKS.sepolia.chainId,
) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({
error: "The transaction could not be sent.",
retryable: false,
});
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: TOKEN,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "mainnet";
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances },
],
},
];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
}
async function confirm() {
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
beforeEach(() => {
state.trackedTokens = [];
state.networkTrackedTokens = {};
});
describe("a token tracked on Sepolia only", () => {
beforeEach(() => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
});
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showTokenTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
test("labels the amount on the error screen that follows", async () => {
await showTokenTransfer();
await confirm();
expect(state.currentView).toBe("error-tx");
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
});
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
await showTokenTransfer([
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// The From and Contract lines, and the decoded Token and Recipient lines.
test("every address links to the Sepolia explorer", async () => {
await showTokenTransfer();
const sepolia = NETWORKS.sepolia.explorerUrl;
expect(node("approve-tx-from").innerHTML).toContain(
`href="${sepolia}/address/${FROM}"`,
);
expect(node("approve-tx-to").innerHTML).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Token")).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Recipient")).toContain(
`href="${sepolia}/address/${RECIPIENT}"`,
);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
}
});
// Chain id 1337 names no network here, so it has no explorer to link to.
test("a transaction on a chain id that names no network is shown with no address links", async () => {
await showTokenTransfer([], "0x539");
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-network").textContent).toBe(
"Unknown network (chain id 1337)",
);
expect(node("approve-tx-from").innerHTML).toContain(FROM);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain("href=");
}
});
-192
View File
@@ -1,192 +0,0 @@
// The USD value on the approval screen's Value and Network fee lines, for a
// site's ETH send.
//
// It was worked out from the four-decimal figure the line shows, at the price
// on the active network, so the same value read a few cents different here than
// on the confirmation screen, which prices the exact amount at the price on the
// transaction's own network, and a transaction on another network than the
// active one was priced at the active one's rate, its fee too
// (https://git.eeqj.de/sneak/AutistMask/issues/614).
//
// Driven through the real approval screen, against a minimal DOM stub in the
// shape tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { prices, clearPrices } = require("../src/shared/prices");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the approval screen, with the network `networkId` active, for the
// site's transaction sending 1.23414 ETH to RECIPIENT on the chain `chainId`.
async function showEthSend(chainId, networkId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "1234140000000000000",
data: "0x",
accessList: [],
},
});
},
};
state.networkId = networkId;
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [{ address: FROM, index: 0, balance: "0" }],
},
];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.currentView = null;
approval.init({});
await approval.show(1);
}
beforeEach(() => {
prices.ETH = 2000;
});
afterEach(() => {
clearPrices();
});
// 1.23414 ETH reads 1.2341 ETH. At $2,000 the exact value is $2,468.28, the
// figure the confirmation screen shows for it; the shown figure would be
// $2,468.20.
test.each([
[
"a mainnet transaction is priced from its exact value",
NETWORKS.mainnet.chainId,
"mainnet",
"1.2341 ETH ($2,468.28)",
],
[
"a mainnet transaction shown with Sepolia active is priced at the mainnet price",
NETWORKS.mainnet.chainId,
"sepolia",
"1.2341 ETH ($2,468.28)",
],
[
"a Sepolia transaction shown with mainnet active has no USD value",
NETWORKS.sepolia.chainId,
"mainnet",
"1.2341 SepoliaETH",
],
// Chain id 1337 names no network here, so nothing knows its price.
[
"a transaction on a chain id that names no network has no USD value",
"0x539",
"mainnet",
"1.2341 ETH",
],
])("%s", async (_label, chainId, networkId, expected) => {
await showEthSend(chainId, networkId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-value").textContent).toBe(expected);
});
// The Network fee line is priced on the transaction's network too. 21000 gas at
// up to 2 gwei is 0.000042 ETH, $0.08 at $2,000.
test.each([
[
"the fee of a mainnet transaction shown with Sepolia active is priced at the mainnet price",
NETWORKS.mainnet.chainId,
"sepolia",
"0.00004 ETH ($0.08)",
],
[
"the fee of a Sepolia transaction shown with mainnet active has no USD value",
NETWORKS.sepolia.chainId,
"mainnet",
"0.00004 SepoliaETH",
],
])("%s", async (_label, chainId, networkId, expected) => {
await showEthSend(chainId, networkId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-fee").textContent).toBe(expected);
});
+1 -8
View File
@@ -154,14 +154,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => { test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], { reopenedOn("settings", ["main", "transaction"], {
viewData: { viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
tx: {
hash: "0xdead",
from: ADDRESS,
to: ADDRESS,
chainId: "0x1",
},
},
}); });
goBack(); goBack();
expect(calls).toEqual(["transactionDetail"]); expect(calls).toEqual(["transactionDetail"]);
+1 -49
View File
@@ -19,10 +19,7 @@
// that followed persisted the pre-refresh values — while still stamping // that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo. // lastBalanceRefresh, suppressing the redo.
// //
// A network switch landing inside a balance refresh's round trip is covered at // Both use the real persistence path over a cloning storage stub. Nothing here
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
//
// All use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it // asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right. // holds against any implementation that gets the outcome right.
@@ -398,48 +395,3 @@ describe("a balance refresh under another handler's state read", () => {
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE); expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
}); });
}); });
describe("a balance refresh under a network switch", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
// address's balances, and a refresh that started on the previous network
// must not write that network's back over them when it returns. The same
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// The user switches to Ethereum Mainnet in Settings while the round
// trip is out.
const during = bg.persisted();
applyChainSwitchFields(during, MAINNET.id);
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
expect(ranOn).toEqual(["sepolia", "mainnet"]);
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});
-161
View File
@@ -1,161 +0,0 @@
// A balance refresh the popup started on one network, and that returns after
// the network was switched to another, writes nothing: the switch cleared every
// address's balances, and the previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
// by the user in this popup's Settings, or by the background after the user
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
afterEach(() => {
cleanupPopup();
});
// Starts the ten-second refresh and holds it open. The balance stand-in writes
// Ethereum Mainnet's balances in place, on the records handed in, as the real
// refreshBalances() does, once the returned function is called. Resolves with
// that function and the refresh still running.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
});
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
// recorded as done.
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The switch is made only once the refresh's save, holding the lock, has
// written Ethereum Mainnet's balances, and the switch then clears them.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
// The next read of the stored record, the refresh's save's, is held.
const get = page.storage.local.get;
const read = get.getMockImplementation();
let releaseRead;
get.mockImplementationOnce(async (key) => {
await new Promise((resolve) => {
releaseRead = resolve;
});
return read(key);
});
const finishing = refresh.finish();
await page.settle();
// The user picks Sepolia meanwhile; the switch's own save waits for the
// refresh's save to let the lock go.
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
releaseRead();
await finishing;
await switching;
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
test("a refresh that returns after the background switched the network writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { saveState } = require("../src/shared/state");
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told of it
// yet.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
await refresh.finish();
expectNothingStored(page);
// Nor does a later save by this page, such as every navigation makes.
await saveState();
expectNothingStored(page);
});
+1 -4
View File
@@ -446,10 +446,7 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => { global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP); bg.send(msg, POPUP);
}; };
const { state, loadState } = require("../src/shared/state"); await require("../src/shared/state").loadState();
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({ require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal, pageClosed: new AbortController().signal,
}); });
+1 -1
View File
@@ -158,7 +158,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { const {
Interface, Interface,
+1 -1
View File
@@ -189,7 +189,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { const {
BaseWallet, BaseWallet,
+1 -1
View File
@@ -101,7 +101,7 @@ describe("the flash line the message is shown in", () => {
// //
// The line cuts a message too long for it with an ellipsis (see // The line cuts a message too long for it with an ellipsis (see
// showFlash() in src/popup/views/helpers.js). The assertions that // showFlash() in src/popup/views/helpers.js). The assertions that
// measure that, in a real browser at the documented 396x600 popup, are // measure that, in a real browser at the documented 360x600 popup, are
// "a rejected dust threshold shifts no layout (#233)" and "an over-long // "a rejected dust threshold shifts no layout (#233)" and "an over-long
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by // flash message keeps to one line (#252)" in tests/e2e/run.js, run by
// make test-e2e. They are not in make check because REPO_POLICIES.md // make test-e2e. They are not in make check because REPO_POLICIES.md
-12
View File
@@ -867,14 +867,6 @@ step(
"the page was not told of the approved switch: " + "the page was not told of the approved switch: " +
JSON.stringify(events), JSON.stringify(events),
); );
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await d.switchToWindow(env.popupWindow);
await d.waitFor(
"the open popup to show the testnet banner",
`const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";`,
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet); await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network"); await d.click("#btn-approve-network");
@@ -889,10 +881,6 @@ step(
isDeepStrictEqual(await storedNetwork(env), before), isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints", "switching back did not restore the mainnet network and endpoints",
); );
await d.waitFor(
"the open popup to drop the testnet banner",
`return document.getElementById("debug-banner") === null;`,
);
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
}, },
); );
+12 -43
View File
@@ -60,14 +60,6 @@ const TEST_TIMEOUT_MS = 120000;
// trailing drain in main(). // trailing drain in main().
const TRAILING_WATCH_MS = 1500; const TRAILING_WATCH_MS = 1500;
// The popup's size, as README gives it: `body` in src/popup/styles/main.css
// is 396px wide, and src/background/index.js opens an approval window at
// the same 396x600. Pages in this context otherwise get Playwright's
// 1280x720 default, where the page never scrolls sideways and a button
// below the popup's fold is still on screen, so a layout measured there
// passes whatever the page does.
const POPUP_VIEWPORT = { width: 396, height: 600 };
const tests = []; const tests = [];
function test(name, fn) { function test(name, fn) {
@@ -367,14 +359,11 @@ async function leaveTransactionDetail(page) {
await openAddressDetail(page); await openAddressDetail(page);
} }
// Back out to Home from wherever the previous test finished, and wait for the // Back out to Home from wherever the previous test finished.
// save that navigation makes, so a test reading the stored stack next reads
// the one Home left.
async function goHome(page) { async function goHome(page) {
await leaveTransactionDetail(page); await leaveTransactionDetail(page);
await page.click("#btn-address-back"); await page.click("#btn-address-back");
await visible(page, "#view-main"); await visible(page, "#view-main");
await waitForPersisted(page, "currentView", "main", "back on Home");
} }
// One field of the popup's state as it was actually persisted, read out of // One field of the popup's state as it was actually persisted, read out of
@@ -651,8 +640,7 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address"); await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the // No wait: the confirm renders the balance list before it shows the
// screen. The refresh it starts draws the list again, with the row still // screen, and nothing renders the list again while the screen is up.
// in it.
const row = env.page.locator( const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]', '#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol }, { hasText: STUB_TOKEN.symbol },
@@ -1897,6 +1885,12 @@ test("a network switch in Settings shows and removes the testnet banner at once
// ------------------------------------------------ dust threshold (#233) // ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
// context otherwise get Playwright's 1280x720 default, at which the flash
// line has room for any plausible message and never wraps — measuring
// there would pass for every string and prove nothing.
const POPUP_VIEWPORT = { width: 360, height: 600 };
// Everything below the flash line that must not move when it fills, plus // Everything below the flash line that must not move when it fills, plus
// the height of the line itself. Runs in the page. // the height of the line itself. Runs in the page.
// //
@@ -2145,7 +2139,7 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
// ------------------------------------------ add wallet Import button (#493) // ------------------------------------------ add wallet Import button (#493)
// At 396x600 the add wallet screen's Import button already starts near the // At 360x600 the add wallet screen's Import button already starts near the
// bottom of the popup, so its password error line sits beside the button // bottom of the popup, so its password error line sits beside the button
// rather than above it. Measures the button and the line empty and again // rather than above it. Measures the button and the line empty and again
// filled with the longest error addWallet.js puts there. Runs in the page. // filled with the longest error addWallet.js puts there. Runs in the page.
@@ -3437,14 +3431,10 @@ test("six failed receipt lookups in a row end on the unreachable-network message
// timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as // timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect. // anything that names the defect.
// The issue's reproduction, with the frame sized to the popup. // Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL = const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' + '<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:' + 'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
POPUP_VIEWPORT.width +
"px;height:" +
POPUP_VIEWPORT.height +
'px;z-index:99999"></iframe>';
// What a correctly escaped and capped render of it reads as: the first // What a correctly escaped and capped render of it reads as: the first
// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than // MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than
@@ -4866,12 +4856,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " + "the page was not told of the approved switch: " +
JSON.stringify(events), JSON.stringify(events),
); );
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await env.page.waitForFunction(() => {
const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";
});
const restored = await openNetworkPrompt( const restored = await openNetworkPrompt(
env, env,
@@ -4893,9 +4877,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before), isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints", "switching back did not restore the mainnet network and endpoints",
); );
await env.page.waitForFunction(
() => document.getElementById("debug-banner") === null,
);
}); });
// Switching the network in Settings tells the page too, as an approved site // Switching the network in Settings tells the page too, as an approved site
@@ -5045,20 +5026,8 @@ function addressRowReport(page) {
}); });
} }
// Measured at the popup's 396x600 (POPUP_VIEWPORT). Every page in this
// context, approval windows included, is otherwise 1280x720 whatever size
// the extension asks for, and a page that wide never scrolls sideways. The
// page is then put back to the size it had, so the size a later step runs at
// does not depend on whether this one ran.
async function assertAddressesFit(page, where) { async function assertAddressesFit(page, where) {
const sizeBefore = page.viewportSize(); const report = await addressRowReport(page);
await page.setViewportSize(POPUP_VIEWPORT);
let report;
try {
report = await addressRowReport(page);
} finally {
await page.setViewportSize(sizeBefore);
}
assert( assert(
report.rows.length > 0, report.rows.length > 0,
where + ": no address rows were rendered, so nothing was measured", where + ": no address rows were rendered, so nothing was measured",
-1
View File
@@ -88,7 +88,6 @@ describe.each([
showFlash: (msg) => flashes.push(msg), showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s, escapeHtml: (s) => s,
goBack: () => {}, goBack: () => {},
trackToken: () => {},
})); }));
require("../src/popup/views/" + view).init({ require("../src/popup/views/" + view).init({
+1 -1
View File
@@ -151,7 +151,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks"); const { NETWORKS } = require("../src/shared/networks");
+1 -1
View File
@@ -364,7 +364,7 @@ describe("a save that fails", () => {
return storage; return storage;
} }
test("is reported when the caller does not await it", async () => { test("is reported, not swallowed by the save queue", async () => {
const storage = failingStorage(unversionedValidProfile()); const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } = const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage); loadStateModule(storage);
+2 -22
View File
@@ -248,13 +248,6 @@ const CONTRACT = [
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]], floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText), 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", field: "viewStack",
kind: KIND.ENTRIES, kind: KIND.ENTRIES,
@@ -472,19 +465,12 @@ const CONTRACT = [
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
contractAddress: 42, contractAddress: 42,
chainId: "0x1",
}, },
}, },
views: ["transaction"], views: ["transaction"],
restored: false, restored: false,
}, },
// A transaction whose chain id is missing or names no network has // A chain id naming no network has no explorer to link to.
// no explorer to link to and no Blockscout to be read from.
{
value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS } },
views: ["transaction"],
restored: false,
},
{ {
value: { value: {
tx: { tx: {
@@ -850,13 +836,7 @@ const WELL_FORMED_DATA = {
message: "boom", message: "boom",
to: ADDRESS, to: ADDRESS,
decoded: { details: [{ address: TOKEN_ADDRESS }] }, decoded: { details: [{ address: TOKEN_ADDRESS }] },
tx: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: null,
chainId: "0x1",
},
pendingTx: { pendingTx: {
token: "ETH", token: "ETH",
from: ADDRESS, from: ADDRESS,
+1 -1
View File
@@ -58,7 +58,7 @@ function restoringOnto(view, viewData) {
test("the transaction screen's Blockscout request reaches the stub", async () => { test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup( const page = await bootPopup(
restoringOnto("transaction", { restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, chainId: "0x1" }, tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
}), }),
); );
-300
View File
@@ -1,300 +0,0 @@
// An open popup follows a network switch the background saves once the user
// approves a site's request: it takes the new network from the stored record
// and draws the screen it is on for it, as a switch in Settings does
// (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real
// popup over a stored profile on Ethereum Mainnet. The background's write is
// the storage stub's set, which the stub reports to the page as the browser
// does.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const { NETWORKS } = require("../src/shared/networks");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
afterEach(() => {
cleanupPopup();
});
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail,
// with DAI tracked there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() {
return bootPopup(profileOnAddress());
}
// The background saves a switch to `networkId`, as its updateState() does
// once the user approves a site's request.
async function backgroundSwitch(page, networkId) {
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, networkId);
await page.storage.local.set({ autistmask: record });
await page.settle();
await page.settle();
}
test("the popup open on AddressDetail shows the new network and its balances", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("DAI");
expect(page.node("debug-banner")).toBeNull();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).toContain("SepoliaETH");
expect(balances).not.toContain("DAI");
expect(page.text("debug-banner")).toBe("[TESTNET]");
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
});
test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["settings"]);
expect(page.value("settings-network")).toBe("sepolia");
expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST");
expect(page.text("debug-banner")).toBe("[TESTNET]");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).not.toContain("DAI");
});
// Neither can be drawn again for the new network: Send cannot be rendered from
// saved state, and DAI was picked on Ethereum Mainnet, where alone it is
// tracked. A reopened popup goes to Home in their place.
test.each([
["Send", (page) => page.click("btn-send")],
[
"AddressToken for DAI",
async (page) => {
const { state } = require("../src/shared/state");
state.selectedToken = MAINNET_TOKEN.address;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
},
],
])("the popup open on %s goes to Home", async (_view, open) => {
const page = await bootOnAddress();
await open(page);
expect(page.visibleViews()).not.toEqual(["address"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
});
// The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
const writes = set.mock.calls.length;
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.settle();
expect(get).toHaveBeenCalledTimes(reads + 1);
expect(set).toHaveBeenCalledTimes(writes + 1);
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
});
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("mainnet");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
test("a record that names no network is not followed", async () => {
const page = await bootOnAddress();
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
await set({
autistmask: {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [{ type: "hd", name: "Main", addresses: [] }],
},
});
await page.settle();
expect(get).toHaveBeenCalledTimes(reads);
expect(page.visibleViews()).toEqual(["address"]);
});
// A record that takes the switch from the switched record ends up as that
// record, balances the background fetched since included.
test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => {
const before = unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
networkEndpoints: {
sepolia: {
rpcUrl: "http://127.0.0.1:8545",
blockscoutUrl: "http://127.0.0.1:4000/api/v2",
},
},
lastBalanceRefresh: 1,
tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 },
fraudContracts: ["0x1111111111111111111111111111111111111111"],
});
const switched = structuredClone(before);
applyChainSwitchFields(switched, "sepolia");
switched.wallets[0].addresses[0].balance = "2.0";
const follower = structuredClone(before);
copyChainSwitchFields(follower, switched);
expect(follower).toEqual(switched);
});
+1 -1
View File
@@ -97,7 +97,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send"); const send = require("../src/popup/views/send");
+1 -1
View File
@@ -151,7 +151,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
+1 -1
View File
@@ -146,7 +146,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { Transaction, Wallet, formatEther } = require("ethers"); const { Transaction, Wallet, formatEther } = require("ethers");
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
+1 -1
View File
@@ -160,7 +160,7 @@ describe("the approval-window reproduction", () => {
await approvalWindow.state.loadState(); await approvalWindow.state.loadState();
approvalWindow.helpers.showView("approve-tx"); approvalWindow.helpers.showView("approve-tx");
// showView() does not await its own saveState(); an extra save // showView() does not await its own saveState(); an extra save
// waits for the lock behind that one and so resolves only after it, // joins the same queue and only resolves once that one has too,
// which is the black-box way to know it landed. // which is the black-box way to know it landed.
await approvalWindow.state.saveState(); await approvalWindow.state.saveState();
-96
View File
@@ -1,96 +0,0 @@
// A popup save reads the stored record, merges this page's changes into it
// and writes it back. The background's write of a network the user approved
// used to be able to land between that read and that write, and the popup's
// write then put the old network back
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every write of the record
// now holds a lock from its read to its write.
//
// The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and a page outside
// a Firefox private window share the browser's
// (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub");
function loadContexts(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return {
popup: require("../src/shared/state"),
background: require("../src/background/state"),
applyChainSwitchFields: require("../src/shared/chainSwitchFields")
.applyChainSwitchFields,
};
}
// Lets every pending promise callback run, and then some.
function nextMacrotask() {
return new Promise((resolve) => setImmediate(resolve));
}
afterEach(() => {
delete globalThis.chrome;
jest.restoreAllMocks();
});
test("a network the background switches to during a popup save stays stored", async () => {
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
await popup.loadState();
// The popup navigates, so its save has a change of its own to write.
popup.state.currentView = "settings";
// The popup's save has read the record and is about to write it back when
// the user's approval of a site's switch to mainnet reaches the
// background. Without the lock the background's whole update runs before
// the next macrotask; with it, the update waits for the popup's write.
let backgroundSwitch;
const realSet = storage.local.set.getMockImplementation();
storage.local.set.mockImplementationOnce(async (items) => {
backgroundSwitch = background.updateState((fresh) =>
applyChainSwitchFields(fresh, "mainnet"),
);
await nextMacrotask();
return realSet(items);
});
await popup.saveState();
await backgroundSwitch;
const stored = storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.currentView).toBe("settings");
});
test("after a save that failed, the next save still keeps a network the background switches to during it", async () => {
jest.spyOn(console, "error").mockImplementation(() => {});
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
await popup.loadState();
popup.state.currentView = "settings";
// The first save's write is refused, so it gives the lock back having
// failed.
storage.local.set.mockRejectedValueOnce(new Error("quota exceeded"));
await expect(popup.saveState()).rejects.toThrow("quota exceeded");
// The next save is between its read and its write when the background
// switches to mainnet, as in the test above.
let backgroundSwitch;
const realSet = storage.local.set.getMockImplementation();
storage.local.set.mockImplementationOnce(async (items) => {
backgroundSwitch = background.updateState((fresh) =>
applyChainSwitchFields(fresh, "mainnet"),
);
await nextMacrotask();
return realSet(items);
});
await popup.saveState();
await backgroundSwitch;
const stored = storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.currentView).toBe("settings");
});
-27
View File
@@ -1,27 +0,0 @@
// `navigator.locks` for the unit tests, which run on node, where there is none.
//
// One lock per name: granted in the order it was asked for, and let go when
// the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's,
// except a page in a Firefox private window (README, "Stored state and its
// version").
//
// Run before each test file by `setupFiles` in package.json.
// For each name, a promise that settles once the last request for it has
// let the lock go.
const released = new Map();
function request(name, callback) {
const previous = released.get(name) || Promise.resolve();
const result = previous.then(() => callback({ name, mode: "exclusive" }));
released.set(
name,
result.catch(() => {}),
);
return result;
}
globalThis.navigator = { locks: { request } };
+1 -1
View File
@@ -311,7 +311,7 @@ async function bootPopup(stored, options) {
const windowListeners = {}; const windowListeners = {};
globalThis.chrome = { globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged }, storage: { local: storage.local },
runtime: { runtime: {
sendMessage: jest.fn(async () => ({})), sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p, getURL: (p) => "chrome-extension://autistmask/" + p,
+5 -30
View File
@@ -18,9 +18,7 @@
// a storage stub by hand. // a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways. // One storage area: get, set and remove over its own store, cloning both ways.
// `changed` is handed what each set or remove changed, in the shape function makeArea(store, tick) {
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
const get = jest.fn(async (key) => { const get = jest.fn(async (key) => {
tick(); tick();
if (key === undefined || key === null) return structuredClone(store); if (key === undefined || key === null) return structuredClone(store);
@@ -36,25 +34,14 @@ function makeArea(store, tick, changed) {
const set = jest.fn(async (items) => { const set = jest.fn(async (items) => {
tick(); tick();
const changes = {};
for (const k of Object.keys(items)) { for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]); store[k] = structuredClone(items[k]);
} }
changed(changes);
}); });
const remove = jest.fn(async (key) => { const remove = jest.fn(async (key) => {
tick(); tick();
const changes = {}; for (const k of Array.isArray(key) ? key : [key]) delete store[k];
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
}); });
return { get, set, remove }; return { get, set, remove };
@@ -67,22 +54,12 @@ function makeArea(store, tick, changed) {
function makeStorageStub(initial, onOp) { function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {}; const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {}); const tick = onOp || (() => {});
// storage.onChanged: after a set or remove has finished, every listener is const local = makeArea(store, tick);
// called with what it changed and the area's name, as the browser calls
// them for a write by any page or the background.
const listeners = [];
const announce = (areaName) => (changes) => {
Promise.resolve().then(() => {
for (const fn of listeners) fn(structuredClone(changes), areaName);
});
};
const local = makeArea(store, tick, announce("local"));
return { return {
// Drop this straight in as chrome.storage. // Drop this straight in as chrome.storage.
local, local,
session: makeArea({}, tick, announce("session")), session: makeArea({}, tick),
onChanged: { addListener: (fn) => listeners.push(fn) },
get: local.get, get: local.get,
set: local.set, set: local.set,
remove: local.remove, remove: local.remove,
@@ -94,9 +71,7 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store) ? structuredClone(store)
: structuredClone(store[key]), : structuredClone(store[key]),
// Seed or replace a record without going through the module under // Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this". Nothing is // test — for standing in as "another page wrote this".
// announced to storage.onChanged, so it also stands in for the time
// before the browser tells this page of such a write.
write: (key, value) => { write: (key, value) => {
store[key] = structuredClone(value); store[key] = structuredClone(value);
}, },
+9 -83
View File
@@ -10,11 +10,7 @@
// //
// Its links open the explorer of the network the transaction was sent on, // Its links open the explorer of the network the transaction was sent on,
// whichever network is active // whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is // (https://git.eeqj.de/sneak/AutistMask/issues/584).
// read from that network's Blockscout, the one Settings has for it
// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name
// the token, and scale its amount, from what the wallet knows of that
// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
// //
// The row is the one the real history parser makes from the explorer's answer, // The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row // opened through transactionDetail.show(), which is what a click on the row
@@ -30,10 +26,10 @@ jest.mock("../src/shared/log", () => ({
warnf: () => {}, warnf: () => {},
errorf: () => {}, errorf: () => {},
}, },
debugFetch: async (url) => { debugFetch: async (url) => ({
mockAsked.push(url); ok: true,
return { ok: true, json: async () => mockExplorerAnswer(url) }; json: async () => mockExplorerAnswer(url),
}, }),
setRuntimeDebug: () => {}, setRuntimeDebug: () => {},
isDebug: () => false, isDebug: () => false,
})); }));
@@ -48,8 +44,6 @@ const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2"; const EXPLORER = "https://explorer.example/api/v2";
// A Blockscout that knows nothing of the transaction.
const OTHER_EXPLORER = "https://other-explorer.example/api/v2";
const HOLDER = "0x0000000000000000000000000000000000000a11"; const HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name. // A token the wallet tracks and the bundled token list does not name.
@@ -62,9 +56,6 @@ const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)", "function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); ]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
// Every URL asked of a Blockscout, in order.
const mockAsked = [];
// The explorer's answers: the transfer in HOLDER's token transfers, and the // The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract. // transaction itself, sent to the token contract.
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
@@ -158,7 +149,6 @@ beforeEach(() => {
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER; state.blockscoutUrl = EXPLORER;
state.networkEndpoints = {};
state.wallets = [ state.wallets = [
{ {
name: "Main", name: "Main",
@@ -167,7 +157,6 @@ beforeEach(() => {
}, },
]; ];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }]; state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
@@ -199,13 +188,11 @@ test("a token transfer's decoded details name the token contract and the amount
); );
}); });
// Mainnet is active, and its Blockscout knows nothing of the transaction. The // Mainnet is active and the row was read from Sepolia's explorer. The stub
// row was read from the Blockscout Settings remembers for Sepolia, and the // explorer answers whichever network asks, so every link on the screen is
// screen reads the transaction from there too, so every link on it is drawn. // drawn.
test("with mainnet active, a Sepolia transaction is read from Sepolia's Blockscout and its links open the Sepolia explorer", async () => { test("with mainnet active, a Sepolia transaction's links open the Sepolia explorer", async () => {
state.networkId = "mainnet"; state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(
HOLDER, HOLDER,
EXPLORER, EXPLORER,
@@ -230,64 +217,3 @@ test("with mainnet active, a Sepolia transaction is read from Sepolia's Blocksco
`${sepolia}/address/${RECIPIENT}`, `${sepolia}/address/${RECIPIENT}`,
]); ]);
}); });
test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia transaction is asked of Sepolia's default Blockscout", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
mockAsked.length = 0;
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(mockAsked).toEqual([
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
]);
});
// Open the Sepolia transfer's detail screen with mainnet active, and wait for
// its decoded details.
async function showSepoliaTransferWithMainnetActive() {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
state.trackedTokens = [];
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
state.wallets[0].addresses[0].tokenBalances = [
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
];
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).not.toContain("MDOL");
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
-1
View File
@@ -161,7 +161,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
); );
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet"); await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet"); expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]); expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
-27
View File
@@ -150,8 +150,6 @@ beforeEach(() => {
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl; state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {}; state.networkEndpoints = {};
state.wallets = []; state.wallets = [];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
@@ -629,31 +627,6 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
expectSepoliaLinks(); expectSepoliaLinks();
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/629
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
function expectTokenNamed() {
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold">TDOL</div>',
);
}
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
const decoded = {
name: "Transfer",
details: [{ label: "Token", address: TOKEN, isToken: true }],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectTokenNamed();
elements.clear();
txStatus.renderSuccess();
expectTokenNamed();
});
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => { test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() { function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain( expect(getElement("error-tx-to").innerHTML).toContain(
+41 -1528
View File
File diff suppressed because it is too large Load Diff
+48 -604
View File
@@ -7,12 +7,9 @@
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks // balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap // (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), // leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and
// what each PERMIT2_TRANSFER_FROM moves // what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an // (https://git.eeqj.de/sneak/AutistMask/issues/611).
// UNWRAP_WETH makes the output ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/607 and
// https://git.eeqj.de/sneak/AutistMask/issues/642).
// //
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -163,8 +160,8 @@ function transfer(command, token, recipient, amount) {
]; ];
} }
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend. // PERMIT2_PERMIT (0x0a) of `amount` of `token`, for `spender` to spend.
function permit2Permit(token, amount) { function permit2Permit(token, amount, spender) {
return [ return [
0x0a, 0x0a,
coder.encode( coder.encode(
@@ -172,7 +169,7 @@ function permit2Permit(token, amount) {
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)", "tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes", "bytes",
], ],
[[[token, amount, 0, 0], ROUTER, 0n], "0x"], [[[token, amount, 0, 0], spender, 0n], "0x"],
), ),
]; ];
} }
@@ -271,28 +268,18 @@ const SWAP_USDT_FOR_ETH = [
[[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]], [[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]],
), ),
]; ];
const SWAP_USDT_FOR_WETH = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, WETH, 500, 10, ZERO], true, USDT_2000, ONE_WETH, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"]; const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [ const SETTLE_USDT = [
0x0b, 0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
]; ];
// A TAKE of `amount`, by default zero: the open delta, all the step owes. function take(currency, recipient) {
function take(currency, recipient, amount = 0n) {
return [ return [
0x0e, 0x0e,
coder.encode( coder.encode(
["address", "address", "uint256"], ["address", "address", "uint256"],
[currency, recipient, amount], [currency, recipient, 0n],
), ),
]; ];
} }
@@ -400,18 +387,6 @@ describe("each swap command's recipient", () => {
data: (r) => execute(swapToWeth(r)), data: (r) => execute(swapToWeth(r)),
min: "1.0000 WETH", min: "1.0000 WETH",
}, },
{
// Its input has V3_SWAP_EXACT_IN's layout, with amountOut and
// amountInMax in place of amountIn and amountOutMin, and a path
// that lists the token bought first.
kind: "V3_SWAP_EXACT_OUT",
data: (r) =>
execute([
0x01,
v3SwapExactIn(r, ONE_WETH, USDT_2000, WETH, USDT)[1],
]),
min: "1.0000 WETH",
},
{ {
kind: "V4_SWAP with TAKE", kind: "V4_SWAP with TAKE",
data: (r) => data: (r) =>
@@ -544,7 +519,7 @@ describe("an output left in the router is not received", () => {
{ {
kind: "an UNWRAP_WETH to the user, then the swap", kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter), data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 WETH", min: "1.0000 ETH",
}, },
{ {
kind: "the swap, then a WRAP_ETH of zero to the user", kind: "the swap, then a WRAP_ETH of zero to the user",
@@ -581,530 +556,6 @@ describe("an output left in the router is not received", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/607: an UNWRAP_WETH pays out,
// as ETH, the WETH the router holds when it runs. Token Out read ETH whenever
// the output side was WETH, so a swap that paid its WETH to the user before
// the UNWRAP_WETH read as ETH, though the user receives WETH.
describe("an UNWRAP_WETH makes the output ETH only when the router holds it", () => {
test("a swap paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(swapToWeth(MSG_SENDER), unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
test("a swap paying its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH", () => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The SWEEP pays the user all the WETH the router holds, so the
// UNWRAP_WETH finds none of it.
test("a swap paying its WETH to the router, then a SWEEP of it to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
sweep(WETH, MSG_SENDER),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
// The user receives the first step's WETH as WETH and the second's as
// ETH. The second step sets the output side and pays its WETH to the
// router, so the output reads as ETH with the second step's minimum.
test("two swaps, the second paying its WETH to the router, then an UNWRAP_WETH, reads as ETH", () => {
const result = decode(
execute(
swapToWeth(MSG_SENDER),
v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
});
// The same two swaps the other way round. The user receives the first
// step's WETH as ETH and the second's as WETH. The second step sets the
// output side and pays its WETH to the user, so the output reads as WETH
// with the second step's minimum, though the router holds the first's.
test("two swaps, the second paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(
v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
swapToWeth(MSG_SENDER),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
// One V4 step buys at least 1 WETH and TAKEs 0.6 WETH to the user and the
// rest to the router. The user receives 0.6 WETH and the rest as ETH.
// Neither minimum is shown: the decoder does not count how much of the
// step's minimum reaches the router, nor read the UNWRAP_WETH's own.
test("one V4 step paying part of its WETH to the user and the rest to the router, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
withDirtyWord(take(WETH, MSG_SENDER), 1),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// A step after the UNWRAP_WETH that sets the output side sets it anew.
test("a swap paying WETH to the user after an UNWRAP_WETH of an earlier swap's WETH reads as WETH", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
v2Swap(0x08, MSG_SENDER, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
});
// The EXECUTE_SUB_PLAN's own steps, which the decoder does not read, buy
// the user USDC as well, so Token Out is not named.
test("a swap paying its WETH to the router, then an UNWRAP_WETH, then an EXECUTE_SUB_PLAN, names no Token Out", () => {
const subPlan = [
0x21,
coder.encode(
["bytes", "bytes[]"],
[
"0x00",
[
v3SwapExactIn(
MSG_SENDER,
USDT_2000,
USDC_1990,
USDT,
USDC,
)[1],
],
],
),
];
const result = decode(
execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER), subPlan),
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token Out").value).toBe(UNDECODED);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/642: a step between the swap and
// the UNWRAP_WETH may take WETH out of the router or pay it WETH, and a step
// after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each
// route read as ETH with the swap's minimum. A step the decoder read that is a
// PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION of a token other than WETH, and not of ETH to a
// WETH contract, cannot take WETH out of the router or pay anyone WETH; the
// decoder treats every other step as one that may.
describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
(l) => l !== "Min. received",
);
const NO_MINIMUM_ELSEWHERE = PAID_ELSEWHERE.filter(
(l) => l !== "Min. paid out",
);
test.each([
{
kind: "a TRANSFER of 0.0025 WETH",
step: transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips",
step: transfer(0x06, WETH, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15",
step: transfer(0x07, WETH, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with no minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test.each([
{
kind: "a SWEEP whose recipient cannot be read",
step: withDirtyWord(sweep(WETH, OTHER), 1),
},
{
kind: "a V4 SETTLE of 0.5 WETH from the router",
step: v4Swap([
0x0b,
coder.encode(
["address", "uint256", "bool"],
[WETH, ONE_WETH / 2n, false],
),
]),
},
{
kind: "a bridge deposit of 0.5 WETH",
step: acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
},
{
kind: "a PERMIT2_TRANSFER_FROM of 0.5 WETH into the router",
step: permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH / 2n),
},
])("$kind between them reads as ETH with no minimum", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
test.each([
{
kind: "a PERMIT2_PERMIT between them",
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 of another address between them",
steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 of the sender with a zero minimum between them",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "a SWEEP of USDC between them",
steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)],
},
{
kind: "an UNWRAP_WETH into the router, then a SWEEP of its ETH,",
steps: [unwrapWeth(ADDRESS_THIS), sweep(ZERO, MSG_SENDER)],
},
])("$kind reads as ETH with the swap's minimum", ({ steps }) => {
const result = decode(execute(swapToWeth(ADDRESS_THIS), ...steps));
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test("a V4 step that TAKEs its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH with the swap's minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test.each([
{
kind: "a TRANSFER of 100 USDC",
step: transfer(0x05, USDC, OTHER, 100000000n),
},
{
kind: "a PAY_PORTION of 25 bips of USDC",
step: transfer(0x06, USDC, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of USDC",
step: transfer(0x07, USDC, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
test.each([
{
kind: "a TRANSFER of 0.0025 ETH",
step: transfer(0x05, ZERO, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips of its ETH",
step: transfer(0x06, ZERO, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of its ETH",
step: transfer(0x07, ZERO, OTHER, 10n ** 15n),
},
])(
"an UNWRAP_WETH into the router, then $kind to another address, then a SWEEP of its ETH to the user, reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
sweep(ZERO, MSG_SENDER),
),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
sweep(WETH, MSG_SENDER),
wrapEth(ADDRESS_THIS, ONE_WETH),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The router reads the recipient from the last 20 bytes of its word,
// here address(2), the router itself.
test("an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// The first UNWRAP_WETH may pay the router the swap's output as ETH, and
// the WRAP_ETH to the user pays all of it out as WETH, so the last
// UNWRAP_WETH finds none of it.
test.each([
{
kind: "an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user",
steps: [
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
],
},
{
kind: "an UNWRAP_WETH into the router, a WRAP_ETH of all its ETH to the user, then a WRAP_ETH of all its ETH into the router",
steps: [
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
],
},
])(
"$kind, then an UNWRAP_WETH to the user, reads as WETH with no minimum",
({ steps }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
...steps,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A SWEEP of WETH pays out WETH, and ETH paid to a WETH contract pays the
// router WETH for it. The decoder treats each as a step that may pay out
// as WETH the swap's output, which the router holds as ETH.
test.each([
{
kind: "a SWEEP of WETH to the user",
step: sweep(WETH, MSG_SENDER),
},
{
kind: "a SWEEP of its ETH to the WETH contract",
step: sweep(ZERO, WETH),
},
{
kind: "a TRANSFER of 0.5 ETH to the WETH contract",
step: transfer(0x05, ZERO, WETH, ONE_WETH / 2n),
},
{
kind: "a PAY_PORTION of all its ETH to the WETH contract",
step: transfer(0x06, ZERO, WETH, 10000n),
},
])(
"an UNWRAP_WETH into the router, then $kind, reads as WETH with no minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, ONE_WETH / 2n),
sweep(ZERO, MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
// the ETH an earlier V4 step paid the router.
test("an UNWRAP_WETH to the user, then a WRAP_ETH of all the router's ETH to the user, reads as ETH", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The first swap is for at least 0.5 WETH and the second for at least
// 1 WETH. The second sets the output side again, so the first
// UNWRAP_WETH into the router no longer bears on it.
test.each([
{ kind: "a V3 swap step", step: swapToWeth(ADDRESS_THIS) },
{
kind: "a V4 step that TAKEs its WETH to the router",
step: v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
},
])(
"a swap paying WETH to the router, an UNWRAP_WETH into the router, a SWEEP of its ETH to the user, then $kind and an UNWRAP_WETH to the user, reads as ETH with the second swap's minimum",
({ step }) => {
const result = decode(
execute(
v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
ONE_WETH / 2n,
USDT,
WETH,
),
unwrapWeth(ADDRESS_THIS),
sweep(ZERO, MSG_SENDER),
step,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
},
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the // https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
// router stays there unless a later step pays out or spends all of it, not // router stays there unless a later step pays out or spends all of it, not
// only the swap's output. A V4 SETTLE can take more of the user's tokens than // only the swap's output. A V4 SETTLE can take more of the user's tokens than
@@ -1439,17 +890,8 @@ describe("any token left in the router is not received", () => {
"Token In", "Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read // A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place // may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625), // of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
// after the lines the WRAP_ETH's 1 ETH then gets unread ? "Moved from the sender" : "Amount",
// (https://git.eeqj.de/sneak/AutistMask/issues/659).
...(unread
? [
"Moved from the sender",
"Token moved",
"Moved to",
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token // A later swap step the user pays for spends `spent` of a token
// other than Token In. // other than Token In.
...(spent ...(spent
@@ -1466,11 +908,9 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent); expect(detail(result, "Moved from the sender").value).toBe(spent);
} }
if (unread) { if (unread) {
expect( expect(detail(result, "Moved from the sender").value).toBe(
result.details UNDECODED,
.filter((d) => d.label === "Moved from the sender") );
.map((d) => d.value),
).toEqual(["1.0000 ETH", UNDECODED]);
} }
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left); expect(detail(result, "Token moved into the router")).toEqual(left);
@@ -2082,6 +1522,15 @@ describe("a payout of part of the router's output", () => {
), ),
min: "1.0000 WETH", min: "1.0000 WETH",
}, },
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{ {
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL", kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute( data: execute(
@@ -2570,6 +2019,10 @@ describe("a payout that cannot be read is never said to be received", () => {
), ),
], ],
}, },
{
kind: "V3_SWAP_EXACT_OUT",
step: [0x01, swapToWeth(OTHER)[1]],
},
{ {
kind: "EXECUTE_SUB_PLAN", kind: "EXECUTE_SUB_PLAN",
step: [ step: [
@@ -2802,39 +2255,34 @@ describe("the allow-revert flag", () => {
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// The permit's spender is the router at ROUTER, which on every other // A permit to the router caps what the router takes of its token after
// router is an allowance to another spender, so the minimum reads // it, unless the router lets it fail: then what the swap takes gets its
// Min. paid out there. // own lines (https://git.eeqj.de/sneak/AutistMask/issues/625). The swap
// otherwise reads as without the flag.
test.each([ test.each([
{ { router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{ {
router: "UniversalRouterV1_2_V2Support", router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2, address: ROUTER_V1_2,
mark: "", mark: "",
minimum: "Min. paid out",
}, },
{ {
router: "UniversalRouterV2", router: "UniversalRouterV2",
address: ROUTER, address: ROUTER,
mark: " (allowed to fail)", mark: " (allowed to fail)",
minimum: "Min. received", taken: "2000.0000 USDT",
}, },
{ {
router: "UniversalRouterV2_1_2", router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2, address: ROUTER_V2_1_2,
mark: " (allowed to fail)", mark: " (allowed to fail)",
minimum: "Min. paid out", taken: "2000.0000 USDT",
}, },
])( ])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag", "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark'",
({ address, mark, minimum }) => { ({ address, mark, taken }) => {
const steps = [ const steps = [
permit2Permit(USDT, USDT_2000), permit2Permit(USDT, USDT_2000, address),
swapToWeth(MSG_SENDER), swapToWeth(MSG_SENDER),
]; ];
const plain = decode(execute(...steps), USER, address); const plain = decode(execute(...steps), USER, address);
@@ -2846,25 +2294,21 @@ describe("the allow-revert flag", () => {
expect(detail(withFlag, "Steps").value).toBe( expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)", "Permit2 Permit" + mark + " → V3 Swap (Exact In)",
); );
// The permit names the router at ROUTER as its spender, so its const takenLines = [
// amount stands as the Amount figure only there, and only without "Moved from the sender",
// the flag. With the flag it stands on no router and gets lines "Token moved",
// of its own, and the swap step names Token In: the same token, "Moved to",
// its address written the same way.
const permitLines = [
"Permit2 allowance",
"Allowance token",
"Allowance spender",
]; ];
const swapLines = (r) => const rest = (r) =>
r.details.filter( r.details.filter(
(d) => !["Steps", ...permitLines].includes(d.label), (d) => d.label !== "Steps" && !takenLines.includes(d.label),
); );
expect(swapLines(withFlag)).toEqual(swapLines(plain)); expect(rest(withFlag)).toEqual(rest(plain));
expect( expect(detail(plain, "Moved from the sender")).toBeUndefined();
labels(withFlag).filter((l) => permitLines.includes(l)), expect(detail(withFlag, "Moved from the sender")?.value).toBe(
).toEqual(permitLines); taken,
expect(detail(withFlag, minimum).value).toBe("1.0000 WETH"); );
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
}, },
); );
+22 -105
View File
@@ -7,12 +7,6 @@
// only when the transaction is sent to a Universal Router deployment on the // only when the transaction is sent to a Universal Router deployment on the
// network it is for; anywhere else it is left to the raw data. // network it is for; anywhere else it is left to the raw data.
// //
// The transaction history labelled any call named `execute` or `multicall` a
// Swap, whatever address it went to
// (https://git.eeqj.de/sneak/AutistMask/issues/580). It now does so only for
// such a call sent to a Universal Router deployment on the network the
// transaction is on; any other is labelled by its method name.
//
// The transaction detail screen and the approval screen are driven against a // The transaction detail screen and the approval screen are driven against a
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the // minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
// approval screen opened the way tests/swapStatusAmount.test.js opens it. // approval screen opened the way tests/swapStatusAmount.test.js opens it.
@@ -39,7 +33,6 @@ globalThis.chrome = {
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval"); const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval; const { decodeCalldata } = approval;
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
@@ -54,8 +47,6 @@ const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The address with no code from the issue. // The address with no code from the issue.
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A"; const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
// A contract that is not a router, such as a multisig.
const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
@@ -138,13 +129,8 @@ describe("execute() calldata on the approval screen's decoder", () => {
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`. // The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
const TX_HASH = "0x" + "5".repeat(64); const TX_HASH = "0x" + "5".repeat(64);
let mockSentTo = null; let mockSentTo = null;
// The transactions the explorer reports for RECIPIENT's history.
let mockHistory = [];
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
if (url.endsWith("/addresses/" + RECIPIENT + "/transactions")) {
return { items: mockHistory };
}
if (url.endsWith("/transactions/" + TX_HASH)) { if (url.endsWith("/transactions/" + TX_HASH)) {
return { return {
to: { hash: mockSentTo }, to: { hash: mockSentTo },
@@ -200,126 +186,57 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// Open the transaction detail screen with `activeNetworkId` active, for the // Open the transaction detail screen with Sepolia active, for the
// history row `row`, and wait for its decoded details. // transaction the explorer reports sent to `sentTo`, and wait for its
async function showDetail(row, activeNetworkId) { // decoded details.
mockSentTo = row.to; async function showDetailSentTo(sentTo) {
mockSentTo = sentTo;
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId; state.networkId = "sepolia";
state.blockscoutUrl = "https://explorer.example/api/v2"; state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
transactionDetail.show(row); transactionDetail.show({
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
} }
// Open the transaction detail screen with `activeNetworkId` active, for the
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo, activeNetworkId) {
await showDetail(
{
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
},
activeNetworkId,
);
}
describe("execute() calldata on the transaction detail screen", () => { describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => { test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "sepolia"); await showDetailSentTo(SEPOLIA_ROUTER);
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/623
test("sent to a router on its network, with the other network active, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "mainnet");
expect(node("tx-detail-calldata-details").innerHTML).toContain( expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router", "Swap via Uniswap Universal Router",
); );
}); });
test("sent to an ordinary address, it is not decoded", async () => { test("sent to an ordinary address, it is not decoded", async () => {
await showDetailSentTo(ORDINARY, "sepolia"); await showDetailSentTo(ORDINARY);
expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
}); });
test("sent to a router of the other network only, it is not decoded", async () => { test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER, "sepolia"); await showDetailSentTo(MAINNET_ROUTER);
expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
}); });
}); });
// The history row made from the explorer's answer for RECIPIENT's `method`
// call sent to the contract `sentTo` on the network with `chainId`. The
// explorer gives the address in checksummed form.
async function historyRow(method, sentTo, chainId) {
mockHistory = [
{
hash: TX_HASH,
block_number: 7000000,
timestamp: "2026-09-01T00:00:00Z",
from: { hash: RECIPIENT },
to: { hash: getAddress(sentTo), is_contract: true },
value: "0",
method,
status: "ok",
},
];
const rows = await fetchRecentTransactions(
RECIPIENT,
"https://explorer.example/api/v2",
chainId,
);
return rows[0];
}
// The history row's label and TransactionDetail's Type line, for a Sepolia
// transaction. The active network does not decide either.
describe.each([
["execute", "Execute"],
["multicall", "Multicall"],
])("a %s() call in the history", (method, methodLabel) => {
test("sent to a router on its network, it is a Swap", async () => {
const row = await historyRow(method, SEPOLIA_ROUTER, SEPOLIA);
expect(row.directionLabel).toBe("Swap");
await showDetail(row, "mainnet");
expect(node("tx-detail-type").textContent).toBe("Swap");
});
test("sent to another contract, it is labelled by its method name", async () => {
const row = await historyRow(method, OTHER_CONTRACT, SEPOLIA);
expect(row.directionLabel).toBe(methodLabel);
await showDetail(row, "sepolia");
expect(node("tx-detail-type").textContent).toBe("Contract Call");
});
test("sent to a router of the other network only, it is labelled by its method name", async () => {
const row = await historyRow(method, MAINNET_ROUTER, SEPOLIA);
expect(row.directionLabel).toBe(methodLabel);
await showDetail(row, "mainnet");
expect(node("tx-detail-type").textContent).toBe("Contract Call");
});
});
// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia // Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
// transaction that sends SWAP_DATA to `sentTo`. // transaction that sends SWAP_DATA to `sentTo`.
async function showApprovalSentTo(sentTo) { async function showApprovalSentTo(sentTo) {
+1 -1
View File
@@ -120,7 +120,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { AbiCoder, parseUnits } = require("ethers"); const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances"); const { fetchTokenBalances } = require("../src/shared/balances");
+1 -1
View File
@@ -475,7 +475,7 @@ describe("every way to a signature or the private key refuses a defective wallet
to: RECIPIENT, to: RECIPIENT,
value: "0x0", value: "0x0",
data: "0x", data: "0x",
chainId: "0x1", chainId: 1,
nonce: 0, nonce: 0,
gasLimit: "21000", gasLimit: "21000",
maxFeePerGas: "1000000000", maxFeePerGas: "1000000000",