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

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

Model: opus-5-5
2026-10-27 01:46:35 +00:00
31 changed files with 773 additions and 2677 deletions
+155 -158
View File
@@ -17,11 +17,11 @@ and don't implement any crypto. By default the wallet sends user-specific data
only to the Ethereum RPC endpoint. The user can change that endpoint in
Settings. The endpoint defaults to a public node.
Out of the box the extension contacts one service, the configured RPC node. The
wallet uses the node for:
Out of the box the extension contacts two services:
- blockchain interactions
- USD prices on mainnet, read from Chainlink price feed contracts
- the configured RPC node, for blockchain interactions
- a public CoinDesk API (no API key), for realtime prices. The API receives
token symbols and nothing else.
Two more services are sent none of the user's data until the user ticks a
checkbox for each. Welcome and Settings both offer the two checkboxes:
@@ -902,14 +902,13 @@ src/
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule
prices.js — ETH/USD and token/USD from Chainlink price feeds
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
the active network, read from storage
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
read from storage for the requests those settings gate
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
price feeds (hardcoded)
tokenList.js — top ERC-20 tokens by market cap (hardcoded)
transactions.js — tx history fetching + anti-poisoning filters
uniswap.js — Uniswap Universal Router calldata decoder
vault.js — password-based encryption via libsodium
@@ -1266,7 +1265,9 @@ see Transaction Decoding), do not always read as a number. They can read:
the same words. `Unlimited`, `All available (V4 open delta)` and
`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
its `amountOut`, the exact amount it buys.
its `amountOut`, the exact amount it buys. When the router may hold ETH from
an earlier step, the `Moved from the sender` line of ETH that the user pays a
V4 step also reads `Up to <amount>` (see Transaction Decoding).
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
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
@@ -1277,18 +1278,22 @@ see Transaction Decoding), do not always read as a number. They can read:
step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
a V2, V3 or V4 swap's minimum of zero. The router passes a
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
lines, as a swap step does. Only a check of the transaction's sender's own
balance sets either line; a check of any other address sets neither (see
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail
without reverting the transaction (see Transaction Decoding) guarantees
nothing either, whatever its `minBalance`, and is read as one whose
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail
is likewise read as zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known. The router passes a balance
check whenever the balance is at least `minBalance`, so a zero `minBalance`
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does. Only a check of the
transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1368,10 +1373,10 @@ whole swap undecoded.
#### Partial USD totals
Only a token with a Chainlink price feed has a price, so an address can hold
assets the extension has no price for. Worth zero and worth an unknown amount
are different facts and are never collapsed into one number. `getAddressValue()`
in `src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
Prices are fetched for the top 25 tokens only, so an address can hold assets the
extension has no price for. Worth zero and worth an unknown amount are different
facts and are never collapsed into one number. `getAddressValue()` in
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
holdings, and whether an unpriced holding was left out of it — and every screen
renders it through `formatAddressTotal()`, so the wording cannot drift:
@@ -2277,8 +2282,6 @@ view would leave a wallet one click from deletion.
- Endpoint URL input + "Save" button. "Save" asks the URL for `/stats`
and saves the URL only if that request succeeds. The request carries
no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so
- Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user
ticks it
@@ -2819,9 +2822,6 @@ view would leave a wallet one click from deletion.
- "Export Saved Data" button, and the read-only text box it fills
- What erasing does and does not do, in bold: every wallet stored in this
browser is deleted; nothing on chain changes and no money is moved
- What a wallet can be added again from: a kept copy of its recovery phrase,
private key or extended private key. Without that copy or the exported
copy, everything the wallet holds is gone forever.
- A text input asking for `ERASE MY WALLET` to be typed back
- Error line
- "Erase Saved Data" button
@@ -2853,9 +2853,9 @@ view would leave a wallet one click from deletion.
### External Services
AutistMask is not a fully self-contained offline tool. By default it contacts
only the Ethereum RPC endpoint. USD prices are read from Chainlink price feed
contracts through that endpoint.
AutistMask is not a fully self-contained offline tool. By default it sends the
user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price
API for prices, sending token symbols and nothing else.
Two more services are sent none of the user's data until the user turns on a
setting for each:
@@ -2879,18 +2879,21 @@ Contacted by default:
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. Every ENS lookup is made through the RPC endpoint alone. So a name
does not resolve when its resolver says the name is resolved off-chain. On
mainnet the extension also reads USD prices from Chainlink price feed
contracts (`latestRoundData`). The prices of ETH and of each held or tracked
token with a feed are read as one batched request. That request is sent at
most every 5 minutes while the popup is open. The default endpoint is a public
RPC (configurable by the user to any endpoint they prefer, including a local
node). By default the extension talks to
does not resolve when its resolver says the name is resolved off-chain. The
default endpoint is a public RPC (configurable by the user to any endpoint
they prefer, including a local node). By default the extension talks to
`https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
displaying fiat values. The price is cached for 5 minutes to avoid excessive
requests. No API key required. No user data is sent — only a list of token
symbols. Note that CoinDesk will receive your client IP.
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
Sent the user's data only while its setting is on:
- **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is
@@ -3066,13 +3069,14 @@ hardcoded test phrase.
At the raised log level the console also shows the wallet's addresses with their
balances and ENS names, the token contracts looked up, and a line for each
request made through `debugFetch` in `src/shared/log.js` (the explorer, the RPC
calls a site makes, and the endpoint checks in settings) and for its response. A
request is logged by its HTTP method, the origin of its URL (scheme, host and
port) and, for a JSON-RPC call, the method name; the balance refresh, the token
lookup and a failed endpoint check in settings name the endpoint by its origin
too. The URL's path and query string, where RPC providers put API keys, any user
name and password in it, and the request body are never logged.
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
for its response. A request is logged by its HTTP method, the origin of its URL
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
refresh, the token lookup and a failed endpoint check in settings name the
endpoint by its origin too. The URL's path and query string, where RPC providers
put API keys, any user name and password in it, and the request body are never
logged.
### Key Decisions
@@ -3393,8 +3397,7 @@ Currently supported:
`Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out
@@ -3476,80 +3479,80 @@ Currently supported:
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the
rest taken to another address, reads `5000.0000 USDT`. A later step that pays
an ERC-20 token from the router takes none of that token from the user's
wallet. A V2 exact-in whose `amountIn` is `ALREADY_PAID` takes nothing either.
A V4 `SETTLE` of ETH paid from the router still takes from the user's wallet,
since the router holds the transaction's value. When the decoder does not know
the amount of such a `SETTLE`, as for `CONTRACT_BALANCE`, that `SETTLE` counts
as a later step's take. When an earlier step left ETH in the router, that ETH
may pay part of a `SETTLE` of ETH paid from the router. That `SETTLE` then
gets lines of its own, with `Up to` before its amount. A later take of
`Token In` that only the user's wallet pays is added to the total while both
are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both
paid by the user, read `1000.0000 USDT`. When the decoder does not know a
take's amount, the total is not known and there is no `Amount` line. A
`PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` included,
stands as the `Amount` figure in place of the total, which it does only in the
plain shape of Uniswap's own routes: it comes before the step that sets the
input side, it is the only `PERMIT2_PERMIT` in the transaction and there is no
`PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it
fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it
included, or a token the decoder does not know, and the decoder reads every
step after it in full. Permit2 then lets the router take no more of the token
than that amount, so the permit covers every take of it. A step before the
permit takes under whatever allowance the router already had, so it may take
more than the permit's amount, which would then read lower than what leaves
the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router,
then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid
from the router, read `Amount 2000.0000 USDT`. A later permit or batch may
raise what Permit2 lets the router take, and so may a step the decoder does
not read in full: a command the decoder does not read (listed at the end of
this section), a step whose input it cannot read, in whole or in part, and a
step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the
input side or after it, leaves `Token In` and the `Amount` line as they read
with no permit, unless it lets a spender other than the router take `Token In`
(see below): a permit of 5,000 DAI to another address and then a V3 exact-in
of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do
that exact-in and then a permit of 5,000 DAI to the router. A permit on the
zero address never stands as the `Amount` figure, because Permit2 has no
allowance of ETH. 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);
an ETH take whose line reads `Up to` (see above); 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 gets none when the user pays it none of its
input token. 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, that step 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 `Moved to`, which reads
`The swap step that spends it`. A token the decoder does not know gives the
`Moved from the sender` line alone. A step may also take from the user's
wallet what the decoder did not read: a V4 step whose input or one of whose
`SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2
or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
cannot read, such as a V3 step whose path is shorter than one token address or
has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
rest taken to another address, reads `5000.0000 USDT`. V4 pays a `SETTLE` of
ETH from the router's own ETH, whichever payer it names. So when the router
may hold ETH from an earlier step (see above), the ETH that the user pays a V4
step gets lines of its own (see below). A later step paid from the router
spends what the router holds and takes nothing, and so does a V2 exact-in
whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the
total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in
of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder
does not know a take's amount, the total is not known and there is no `Amount`
line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited`
included, stands as the `Amount` figure in place of the total, which it does
only in the plain shape of Uniswap's own routes: it comes before the step that
sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and
there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may
not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM`
of it included, or a token the decoder does not know, and the decoder reads
every step after it in full. Permit2 then lets the router take no more of the
token than that amount, so the permit covers every take of it. A step before
the permit takes under whatever allowance the router already had, so it may
take more than the permit's amount, which would then read lower than what
leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the
router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000
USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or
batch may raise what Permit2 lets the router take, and so may a step the
decoder does not read in full: a command the decoder does not read (listed at
the end of this section), a step whose input it cannot read, in whole or in
part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step
that sets the input side or after it, leaves `Token In` and the `Amount` line
as they read with no permit, unless it lets a spender other than the router
take `Token In` (see below): a permit of 5,000 DAI to another address and then
a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`,
and so do that exact-in and then a permit of 5,000 DAI to the router. When a
permit's amount stands and the step that sets the input side spends a token
other than the permit's, or one the decoder does not know, 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); one whose amount the decoder
does not know; and one of ETH that the user pays a V4 step when the router may
hold ETH. 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
`Moved to`, which reads `The swap step that spends it`. For ETH that the user
pays a V4 step when the router may hold ETH, `Moved from the sender` reads
`Up to <amount>` for a known amount. For a `WRAP_ETH` that pays its WETH to
anyone but the router, `Moved to` names that recipient instead: `The sender`
for `address(1)`, and otherwise the address. A token the decoder does not know
gives the `Moved from the sender` line alone. A step may also take from the
user's wallet what the decoder did not read: a V4 step whose input or one of
whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs;
a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input
it cannot read, such as a V3 step whose path is shorter than one token address
or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
`EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such
step gives one `Moved from the sender` line among those, reading
`Unknown (could not be decoded)`. That line, or any line for a token the
@@ -3563,46 +3566,40 @@ Currently supported:
on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. For a move of the zero
address, `Moved from the sender` gives the amount in base units and
`Token moved` gives the zero address. For such a move into the router, the
`Paid to` line gives the amount in base units and
`Token moved into the router` gives the zero address. Every `PERMIT2_PERMIT`
whose amount does not stand as the `Amount` figure, before the step that sets
the input side or after it, gives three lines, in step order, after those for
each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose
amount does not stand as the `Amount` figure, before the step that sets the
input side or after it, gives three lines, in step order, after those for each
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose
amount stands gives none: the `Amount` line gives its amount, `Token In` its
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH`
grants, one for each token it names, all for its one spender, gives the same
three lines, in step order with those of each `PERMIT2_PERMIT`. For an
allowance of the zero address, `Permit2 allowance` gives the amount in base
units and `Allowance token` gives the zero address. 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.
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
+16 -9
View File
@@ -70,9 +70,9 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. Out of the box it contacts only the Ethereum RPC
endpoint. USD prices are read from Chainlink price feed contracts through that
endpoint.
not have a backend server. Out of the box it sends your data only to the
Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending
token symbols and nothing else.
Blockscout and Etherscan are sent none of your data until you turn on a setting
for each. The settings are "Use Blockscout" and "Check recipients on Etherscan".
@@ -89,16 +89,10 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node.
On mainnet, AutistMask also reads USD prices through this endpoint. The prices
come from Chainlink's price feed contracts. A token with no Chainlink feed shows
no USD value.
When it is contacted:
- on every balance refresh (every 10 seconds while the popup is open, every 60
seconds in the background)
- on mainnet, to read USD prices, at most every 5 minutes while the popup is
open
- when you review a send to an ENS name
- when a send is prepared and broadcast
- while a pending transaction is polled for its receipt
@@ -137,6 +131,19 @@ sends none of your data.
What gets sent: your Ethereum addresses (to look up balances and transactions)
and the hash of each transaction you open.
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
cached for 5 minutes. No API key is required. This endpoint is not
user-configurable, and it is not contacted at all while you are on a testnet,
where no USD values are shown.
When it is contacted: while the popup is open, at most once every 5 minutes.
What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
balances, no identifying information. As with any request, CoinDesk sees your IP
address.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's
+4 -5
View File
@@ -1994,11 +1994,10 @@
>Erasing the saved data deletes every wallet stored in
this browser.</strong
>
Nothing on the blockchain changes and no money is moved. If
you kept a copy of a wallet's recovery phrase, private key
or extended private key, you can add that wallet again.
Without that copy or the exported copy above, everything the
wallet holds is gone forever.
Nothing on the blockchain changes and no money is moved, but
without the exported copy above, or the recovery phrase for
each wallet written down, everything they hold is gone
forever.
</p>
<p class="text-xs mb-1">
To confirm, type
+1 -1
View File
@@ -96,7 +96,7 @@ async function doRefreshAndRender() {
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
refreshPrices(pageClosed.signal),
refreshPrices(),
refreshBalances(
wallets,
state.rpcUrl,
+14 -47
View File
@@ -22,7 +22,6 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -99,7 +98,6 @@ let loadedTxs = [];
let ensNameMap = new Map();
async function loadTransactions(address) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -116,74 +114,43 @@ async function loadTransactions(address) {
});
const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
names = await resolveEnsNames(
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
// The rows then show no ENS names.
ensNameMap = new Map();
}
}
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
+15 -50
View File
@@ -29,7 +29,6 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -70,7 +69,7 @@ function show() {
// 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(tokenId);
price = getPrice(symbol);
}
currentSymbol = symbol;
@@ -171,7 +170,6 @@ function show() {
}
async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -188,6 +186,16 @@ async function loadTransactions(address, tokenId) {
});
let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only
if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null);
@@ -199,75 +207,32 @@ async function loadTransactions(address, tokenId) {
);
}
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
names = await resolveEnsNames(
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
// The rows then show no ENS names.
ensNameMap = new Map();
}
}
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
+2 -2
View File
@@ -129,7 +129,7 @@ function renderAmount(txInfo) {
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = getPrice(txInfo.token, txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
@@ -157,7 +157,7 @@ function renderBalance(txInfo) {
$("confirm-balance").textContent = "unknown (" + symbol + ")";
return;
}
const price = getPrice(txInfo.token, txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
+2 -2
View File
@@ -439,7 +439,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
html += balanceLine(
t.symbol,
bal,
getPrice(t.address),
getPrice(t.symbol),
t.address.toLowerCase(),
);
seen.add(t.address.toLowerCase());
@@ -450,7 +450,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
html += balanceLine(
t.symbol,
0,
getPrice(t.address),
getPrice(t.symbol),
t.address.toLowerCase(),
);
}
+6 -34
View File
@@ -44,7 +44,6 @@ const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log");
function findActiveAddr() {
@@ -184,7 +183,6 @@ async function loadHomeTxs(ctx) {
}
if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
@@ -204,15 +202,6 @@ async function loadHomeTxs(ctx) {
);
const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set();
let merged = [];
@@ -226,24 +215,14 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
// Persist any newly discovered fraud contracts
if (filtered.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
await saveState();
}
merged = filtered.transactions;
@@ -255,13 +234,6 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list");
if (list) {
list.innerHTML =
+7 -41
View File
@@ -44,12 +44,6 @@ let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
// The network that Settings was last drawn for. A follow of a switch saved
// elsewhere sets `state` to the new network first. Settings is drawn again only
// once the follow's write returns. So both "Save" buttons take this network,
// not the one in `state`.
let shownNetwork = null;
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
@@ -205,7 +199,6 @@ function renderWalletListSettings() {
}
function show() {
shownNetwork = currentNetwork();
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId;
@@ -313,7 +306,6 @@ function showNewSettings() {
state.checkRecipientsOnEtherscan;
if (state.currentView === "settings") {
markViewRendered("settings");
shownNetwork = currentNetwork();
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
@@ -327,34 +319,6 @@ function showNewSettings() {
updateDebugBanner(state.currentView);
}
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
// `networkId`, the one that Settings showed when the user tapped "Save". The
// URL is set in `state` inside the save, holding the lock. So the page cannot
// follow a switch saved elsewhere (followStoredSettings() in
// src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
async function saveEndpoint(field, url, networkId) {
let refused = false;
await saveState((stored) => {
refused =
state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false;
state[field] = url;
stored[field] = url;
});
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else {
showFlash("Saved.");
}
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx);
@@ -366,8 +330,6 @@ function init(_ctx) {
showFlash("Please enter an RPC URL.");
return;
}
// The URL is for the network that Settings shows when "Save" is tapped.
const net = shownNetwork;
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url, {
@@ -386,6 +348,7 @@ function init(_ctx) {
showFlash("Endpoint returned an error.");
return;
}
const net = currentNetwork();
if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + ".");
return;
@@ -401,7 +364,9 @@ function init(_ctx) {
showFlash("Could not reach endpoint.");
return;
}
await saveEndpoint("rpcUrl", url, net.id);
state.rpcUrl = url;
await saveState();
showFlash("Saved.");
});
$("btn-save-blockscout").addEventListener("click", async () => {
@@ -410,7 +375,6 @@ function init(_ctx) {
showFlash("Please enter a Blockscout API URL.");
return;
}
const net = shownNetwork;
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url + "/stats");
@@ -425,7 +389,9 @@ function init(_ctx) {
showFlash("Could not reach endpoint.");
return;
}
await saveEndpoint("blockscoutUrl", url, net.id);
state.blockscoutUrl = url;
await saveState();
showFlash("Saved.");
});
// Each change is made inside the save, holding the lock. So
+1 -19
View File
@@ -47,30 +47,12 @@ function clearPopupCaches() {
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
//
// The save writes only the fields that differ from what this page last saved.
// When this page last saved a balance of "0", clearing that balance is no
// change. So the switch also resets these fields in the stored record:
//
// - every address's balance and token balances;
// - the time of the last balance refresh.
//
// A balance that the background or another page stored since this page's last
// save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
let net = null;
await saveState((stored) => {
await saveState(() => {
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
clearPopupCaches();
});
return net;
+21 -97
View File
@@ -1,105 +1,31 @@
// USD prices, read from Chainlink price feeds on Ethereum mainnet, with USD
// formatting and value aggregation. The feeds are in src/shared/tokenList.js.
// Price fetching with 5-minute cache, USD formatting, value aggregation.
const { Contract } = require("ethers");
const { ETH_USD_FEED, PRICE_FEED_BY_ADDRESS } = require("./tokenList");
const { getProvider } = require("./balances");
const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks");
const { log } = require("./log");
const PRICE_CACHE_TTL = 300000; // 5 minutes
// A reading older than its feed's heartbeat plus this many seconds is no price.
const STALE_MARGIN = 1800; // 30 minutes
const FEED_ABI = [
"function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)",
];
// USD prices: ether's under "ETH", each token's under its lowercased contract
// address.
const prices = {};
let lastFetchedAt = 0;
// Reads the price of ether and of each token any address holds or the user
// tracks, when that token has a feed. Mainnet only. The reads go to the RPC
// endpoint as one batched request, at most every 5 minutes. A failed read is
// logged, and that token then has no price.
//
// `signal`, passed by the popup, is aborted when the popup closes. A read that
// fails once `signal` is aborted is not logged, because closing the popup
// cancelled it.
async function refreshPrices(signal) {
const { state, currentNetwork } = require("./state");
if (currentNetwork().id !== "mainnet") {
async function refreshPrices() {
// Testnet tokens have no real market value — skip price fetching
// and clear any stale mainnet prices so the UI shows no USD values.
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) {
clearPrices();
return;
}
const now = Date.now();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
const feeds = new Map([["ETH", ETH_USD_FEED]]);
for (const address of heldOrTrackedTokens(state)) {
const feed = PRICE_FEED_BY_ADDRESS.get(address);
if (feed) feeds.set(address, feed);
try {
const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched);
lastFetchedAt = now;
} catch {
// prices stay stale on error
}
// ethers sends calls made together on one provider as one batch.
const provider = getProvider(state.rpcUrl, state.networkId);
const read = {};
await Promise.all(
[...feeds].map(async ([key, feed]) => {
try {
const contract = new Contract(feed.address, FEED_ABI, provider);
const round = await contract.latestRoundData();
const price = priceFromRound(round, feed, now);
if (price === null) {
log.warnf(
"price feed reading is stale or not positive",
key,
);
return;
}
read[key] = price;
} catch (e) {
if (signal?.aborted) return;
log.errorf(
"price feed read failed",
key,
e.shortMessage || e.message,
);
}
}),
);
clearPrices();
Object.assign(prices, read);
lastFetchedAt = now;
}
// The lowercased contract address of each token that any address holds or
// that the user tracks.
function heldOrTrackedTokens(state) {
const addresses = new Set();
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
for (const token of addr.tokenBalances || []) {
addresses.add(token.address.toLowerCase());
}
}
}
for (const token of state.trackedTokens || []) {
addresses.add(token.address.toLowerCase());
}
return addresses;
}
// The USD price in a feed's latestRoundData() result, or null when the answer
// is not positive or was written longer ago than the feed's heartbeat plus
// STALE_MARGIN. `now` is in milliseconds.
function priceFromRound(round, feed, now) {
const ageSeconds = now / 1000 - Number(round.updatedAt);
if (ageSeconds > feed.heartbeat + STALE_MARGIN) return null;
if (round.answer <= 0n) return null;
return Number(round.answer) / 10 ** feed.decimals;
}
// Clear all cached prices and reset the fetch timestamp so the
@@ -111,15 +37,14 @@ function clearPrices() {
lastFetchedAt = 0;
}
// Return the USD price of the token at the contract address `token`, or of
// ether when `token` is "ETH". The price is for the network with `chainId`, or
// for the active network when no chain id is given. Returns null on testnet,
// on a chain id that names no network, and for a token with no price.
function getPrice(token, chainId) {
// 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
// that names no network, or for a symbol with no price.
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (!network || network.isTestnet) return null;
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
return prices[symbol] || null;
}
function formatUsd(amount) {
@@ -137,7 +62,7 @@ function formatUsd(amount) {
// What an address is worth, as { usd, partial }.
//
// Only a token with a price feed has a price, so an address can hold real
// Prices are fetched for the top 25 tokens only, so an address can hold real
// assets this code has no price for. Adding up the priced ones and calling the
// result the total states a number the holdings do not support: an address
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
@@ -174,9 +99,8 @@ function getAddressValue(addr) {
// nor make it incomplete. Anything that is not a number at all is not
// a holding this can price either, and is left to the same rule.
if (!(tokenBal > 0)) continue;
const price = prices[token.address.toLowerCase()];
if (price) {
usd += tokenBal * price;
if (prices[token.symbol]) {
usd += tokenBal * prices[token.symbol];
} else {
partial = true;
}
+2 -4
View File
@@ -591,10 +591,8 @@ function reportSaveFailure(err) {
//
// `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. It may also change the stored
// record. A field that this page did not change is then written as
// `beforeWrite` left it. When it returns false, nothing is written; any other
// return lets the save go ahead.
// `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
+3 -19
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
// from the stored profile.
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,11 +7,6 @@
// made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just
// changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState");
@@ -29,15 +24,4 @@ async function storedSettingIsOn(name) {
}
}
// Whether `networkId` is the active network in the stored profile. A failed
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
module.exports = { storedSettingIsOn };
+31 -351
View File
@@ -4,12 +4,12 @@
// Addresses are EIP-55 checksummed.
//
// 511 tokens.
//
// Below the list, USD_PRICE_FEEDS gives the Chainlink USD price feed of each
// token that has one.
const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
const TOKENS = [
{
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
@@ -3597,351 +3597,6 @@ const TOKENS = [
},
];
// Chainlink price feeds on Ethereum mainnet. Each feed is a contract whose
// latestRoundData() returns a USD price and the time the price was written.
// The fields of a feed:
//
// - address: the feed's contract
// - heartbeat: the most seconds the feed goes without a new price
// - decimals: the number of decimal places in the price
//
// Each feed's address and heartbeat come from Chainlink's published list of
// mainnet feeds. Each feed was checked on mainnet by reading its description()
// and decimals().
// The ETH/USD feed, which prices ether and WETH.
const ETH_USD_FEED = {
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
heartbeat: 3600,
decimals: 8,
};
// The USD feed of each token in TOKENS that has one, keyed by the token's
// contract address as TOKENS spells it. A token is paired with its feed by its
// address alone, so a token that copies a listed token's symbol gets no feed.
const USD_PRICE_FEEDS = {
// WETH
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": ETH_USD_FEED,
// USDT
"0xdAC17F958D2ee523a2206206994597C13D831ec7": {
address: "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D",
heartbeat: 86400,
decimals: 8,
},
// BNB
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": {
address: "0x14e613AC84a31f709eadbdF89C6CC390fDc9540A",
heartbeat: 86400,
decimals: 8,
},
// USDC
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": {
address: "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6",
heartbeat: 82800,
decimals: 8,
},
// USDS
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": {
address: "0xfF30586cD0F29eD462364C7e81375FC0C71219b1",
heartbeat: 82800,
decimals: 8,
},
// LINK
"0x514910771AF9Ca656af840dff83E8264EcF986CA": {
address: "0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c",
heartbeat: 3600,
decimals: 8,
},
// USDE
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": {
address: "0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961",
heartbeat: 82800,
decimals: 8,
},
// USD1
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d": {
address: "0xF0d9bb015Cd7BfAb877B7156146dc09Bf461370d",
heartbeat: 86400,
decimals: 8,
},
// DAI
"0x6B175474E89094C44Da98b954EedeAC495271d0F": {
address: "0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9",
heartbeat: 3600,
decimals: 8,
},
// PYUSD
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8": {
address: "0x8f1dF6D7F2db73eECE86a18b4381F4707b918FB1",
heartbeat: 86400,
decimals: 8,
},
// WLFI
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6": {
address: "0x14E5FC91Ddb3f97C33013Cc9fA74F54062Ad1Aa1",
heartbeat: 86400,
decimals: 8,
},
// UNI
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": {
address: "0x553303d460EE0afB37EdFf9bE42922D8FF63220e",
heartbeat: 3600,
decimals: 8,
},
// PAXG
"0x45804880De22913dAFE09f4980848ECE6EcbAf78": {
address: "0x9944D86CEB9160aF5C5feB251FD671923323f8C3",
heartbeat: 86400,
decimals: 8,
},
// AAVE
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": {
address: "0x547a514d5e3769680Ce22B2361c10Ea13619e8a9",
heartbeat: 3600,
decimals: 8,
},
// USDF
"0xFa2B947eEc368f42195f24F36d2aF29f7c24CeC2": {
address: "0xb177857a1799aA5F7fEb5799Fdf12CbE8fdF78B1",
heartbeat: 86400,
decimals: 8,
},
// USDG
"0xe343167631d89B6Ffc58B88d6b7fB0228795491D": {
address: "0x14f0737d6b705259e521EA6E9E3506AC78dBd311",
heartbeat: 86400,
decimals: 8,
},
// SKY
"0x56072C95FAA701256059aa122697B133aDEd9279": {
address: "0xee10fE5E7aa92dd7b136597449c3d5813cFC5F18",
heartbeat: 86400,
decimals: 8,
},
// RLUSD
"0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD": {
address: "0x26C46B7aD0012cA71F2298ada567dC9Af14E7f2A",
heartbeat: 86400,
decimals: 8,
},
// USDTB
"0xC139190F447e929f090Edeb554D95AbB8b18aC1C": {
address: "0x66704DAD467A7cA508B3be15865D9B9F3E186c90",
heartbeat: 86400,
decimals: 8,
},
// ARB
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": {
address: "0x31697852a68433DbCc2Ff612c516d69E3D9bd08F",
heartbeat: 86400,
decimals: 8,
},
// USD0
"0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5": {
address: "0x7e891DEbD8FA0A4Cf6BE58Ddff5a8ca174FebDCB",
heartbeat: 86400,
decimals: 8,
},
// GHO
"0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f": {
address: "0x3f12643D3f6f874d39C2a4c9f2Cd6f2DbAC877FC",
heartbeat: 86400,
decimals: 8,
},
// TUSD
"0x0000000000085d4780B73119b644AE5ecd22b376": {
address: "0xec746eCF986E2927Abd291a2A1716c940100f8Ba",
heartbeat: 86400,
decimals: 8,
},
// EURC
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": {
address: "0x04F84020Fdf10d9ee64D1dcC2986EDF2F556DA11",
heartbeat: 86400,
decimals: 8,
},
// FDUSD
"0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409": {
address: "0xfAA9147190c2C2cc5B8387B4f49016bDB3380572",
heartbeat: 86400,
decimals: 8,
},
// CRV
"0xD533a949740bb3306d119CC777fa900bA034cd52": {
address: "0xCd627aA160A6fA45Eb793D19Ef54f5062F20f33f",
heartbeat: 86400,
decimals: 8,
},
// GRT
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": {
address: "0x86cF33a451dE9dc61a2862FD94FF4ad4Bd65A5d2",
heartbeat: 86400,
decimals: 8,
},
// CRVUSD
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": {
address: "0xEEf0C605546958c1f899b6fB336C20671f9cD49F",
heartbeat: 86400,
decimals: 8,
},
// FRAX, the Legacy Frax Dollar, priced by Chainlink's FRAX / USD feed
"0x853d955aCEf822Db058eb8505911ED77F175b99e": {
address: "0xB9E1E3A9feFf48998E45Fa90847ed4D467E8BcfD",
heartbeat: 3600,
decimals: 8,
},
// ENS
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": {
address: "0x5C00128d4d1c2F4f652C267d7bcdD7aC99C16E16",
heartbeat: 86400,
decimals: 8,
},
// SAND
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": {
address: "0x35E3f7E558C04cE7eEE1629258EcbbA03B36Ec56",
heartbeat: 86400,
decimals: 8,
},
// AUSD
"0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a": {
address: "0xB00341502DfEA6Ced8A5786b4059d29dA5E4D1FD",
heartbeat: 86400,
decimals: 18,
},
// COMP
"0xc00e94Cb662C3520282E6f5717214004A7f26888": {
address: "0xdbd020CAeF83eFd542f4De03e3cF0C28A4428bd5",
heartbeat: 3600,
decimals: 8,
},
// XCN
"0xA2cd3D43c775978A96BdBf12d733D5A1ED94fb18": {
address: "0xeb988B77b94C186053282BfcD8B7ED55142D3cAB",
heartbeat: 86400,
decimals: 8,
},
// CVX
"0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B": {
address: "0xd962fC30A72A84cE50161031391756Bf2876Af5D",
heartbeat: 86400,
decimals: 8,
},
// IMX
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": {
address: "0xBAEbEFc1D023c0feCcc047Bff42E75F15Ff213E6",
heartbeat: 86400,
decimals: 8,
},
// 1INCH
"0x111111111117dC0aa78b770fA6A738034120C302": {
address: "0xc929ad75B72593967DE83E7F7Cda0493458261D9",
heartbeat: 86400,
decimals: 8,
},
// SNX
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": {
address: "0xDC3EA94CD0AC27d9A86C180091e7f78C683d3699",
heartbeat: 86400,
decimals: 8,
},
// DOLA
"0x865377367054516e17014CcdED1e7d814EDC9ce4": {
address: "0xE33592594f72Cc7ec8a05788be8E8455746C3a32",
heartbeat: 86400,
decimals: 8,
},
// FRXUSD
"0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29": {
address: "0x9B4a96210bc8D9D55b1908B465D8B0de68B7fF83",
heartbeat: 86400,
decimals: 8,
},
// YFI
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": {
address: "0xA027702dbb89fbd58938e4324ac03B58d812b0E1",
heartbeat: 86400,
decimals: 8,
},
// RSR
"0x320623b8E4fF03373931769A31Fc52A4E78B5d70": {
address: "0x759bBC1be8F90eE6457C44abc7d443842a976d02",
heartbeat: 86400,
decimals: 8,
},
// ZRX
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": {
address: "0x2885d15b8Af22648b98B122b22FDF4D2a56c6023",
heartbeat: 86400,
decimals: 8,
},
// FRAX, formerly FXS, priced by Chainlink's FXS / USD feed
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": {
address: "0x6Ebc52C8C1089be9eB3945C4350B68B8E4C2233f",
heartbeat: 86400,
decimals: 8,
},
// SXT
"0xE6Bfd33F52d82Ccb5b37E16D3dD81f9FFDAbB195": {
address: "0x2D27d9e1b74936D8E83c4BA118F09A4c4a897f62",
heartbeat: 86400,
decimals: 8,
},
// SUSHI
"0x6B3595068778DD592e39A122f4f5a5cF09C90fE2": {
address: "0xCc70F09A6CC17553b2E31954cD36E4A2d89501f7",
heartbeat: 86400,
decimals: 8,
},
// RPL
"0xD33526068D116cE69F19A9ee46F0bd304F21A51f": {
address: "0x4E155eD98aFE9034b7A5962f6C84c86d869daA9d",
heartbeat: 86400,
decimals: 8,
},
// USDP
"0x8E870D67F660D95d5be530380D0eC0bd388289E1": {
address: "0x09023c0DA49Aaf8fc3fA3ADF34C6A7016D38D5e3",
heartbeat: 86400,
decimals: 8,
},
// SPELL
"0x090185f2135308BaD17527004364eBcC2D37e5F6": {
address: "0x8c110B94C5f1d347fAcF5E1E938AB2db60E3c9a8",
heartbeat: 86400,
decimals: 8,
},
// LUSD
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": {
address: "0x3D7aE7E594f2f2091Ad8798313450130d0Aba3a0",
heartbeat: 86400,
decimals: 8,
},
// STG
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": {
address: "0x7A9f34a0Aa917D438e9b6E630067062B7F8f6f3d",
heartbeat: 86400,
decimals: 8,
},
// KNC
"0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202": {
address: "0xf8fF43E991A81e6eC886a3D281A2C6cC19aE70Fc",
heartbeat: 86400,
decimals: 8,
},
// MUSD (the other MUSD in TOKENS has no feed)
"0xacA92E438df0B2401fF60dA7E4337B687a2435DA": {
address: "0xc90E3460424fb8ea79775089E9053113FEE34Ed0",
heartbeat: 86400,
decimals: 8,
},
};
// Build a map of token address (lowercased) -> its USD price feed.
const PRICE_FEED_BY_ADDRESS = new Map();
for (const [address, feed] of Object.entries(USD_PRICE_FEEDS)) {
PRICE_FEED_BY_ADDRESS.set(address.toLowerCase(), feed);
}
// Return the top N tokens from the list.
function getTopTokens(n) {
return TOKENS.slice(0, n);
@@ -3988,6 +3643,33 @@ for (const t of TOKENS) {
}
}
// Fetch USD prices for the top N tokens from CoinDesk.
// Also always includes ETH. Returns { ETH: 1234.56, LINK: 8.60, ... }.
// n must be <= 30 (API and sanity limit).
async function getTopTokenPrices(n) {
if (n > 30) {
throw new Error("getTopTokenPrices: n must be <= 30, got " + n);
}
const symbols = ["ETH", ...TOKENS.slice(0, n).map((t) => t.symbol)];
const unique = [...new Set(symbols)];
const instruments = unique.map((s) => s + "-USD").join(",");
const url =
COINDESK_API +
"?market=cadli&instruments=" +
instruments +
"&apply_mapping=true";
const resp = await debugFetch(url);
const json = await resp.json();
const prices = {};
for (const [instrument, data] of Object.entries(json.Data || {})) {
const symbol = instrument.split("-")[0];
if (data.VALUE !== undefined) {
prices[symbol] = data.VALUE;
}
}
return prices;
}
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase();
@@ -4008,9 +3690,7 @@ module.exports = {
TOKENS,
TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS,
ETH_USD_FEED,
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens,
getTopTokenPrices,
resolveSymbol,
};
+122 -143
View File
@@ -479,17 +479,6 @@ function tokenInfo(address, sources) {
};
}
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
// The zero address there reads as a token nothing knows: its amount in base
// units, and the zero address as its token.
function permit2TokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: null, decimals: null, address };
}
return tokenInfo(address, sources);
}
// A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
@@ -508,18 +497,16 @@ function amountText(raw, info) {
// The line giving a moved token, or the token of a permit, under `label`, in
// the shape of the Token In line, so the screens show it as they show that
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
// ETH has no address.
function tokenMovedLine(label, info) {
// one. ETH has no address.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol
? info.symbol + " (" + info.address + ")"
: info.address,
address: info.address,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
}
@@ -1253,11 +1240,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputEstablished = false;
// Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see paidByUser in the loop), or a WRAP_ETH,
// whose ETH comes from the transaction's value. A step that pays an
// ERC-20 token from the router takes none of that token from the
// sender.
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
@@ -1350,17 +1340,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
};
// What the steps take from the sender beyond the amount of the step
// that sets the input side, as { token, amount }, in step order.
// After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount.
// that sets the input side, as { token, amount, atMost }, in step
// order. After the loop each joins the Amount line or gets lines of
// its own (see permitStands). A take with `atMost` set takes at most
// its amount, and always gets lines of its own.
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
// nothing.
const addUserPaid = (token, amount) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
// of `token` from the sender, or at most that when `atMost` is set. A
// V2 exact-in of ALREADY_PAID takes nothing.
const addUserPaid = (token, amount, atMost = false) => {
if (amount !== ALREADY_PAID) {
userPaid.push({ token, amount, atMost });
}
};
// A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
@@ -1629,13 +1620,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
// The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees
// nothing. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. A zero
// check sets neither Token Out nor the minimum. Any
// nothing and does not replace a minimum an earlier
// step stated. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. Any
// other minBalance sets the output side as a swap
// does. The check pays nobody.
// does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance;
if (minBalance !== 0n) {
if (!(minBalance === 0n && present(minOutput))) {
setOutput(i, b.token, minBalance, []);
}
keepsRouterWeth = true;
@@ -1803,6 +1794,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
@@ -1832,40 +1826,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own
// ETH, whichever payer the SETTLE names. The router holds
// the transaction's value, and any ETH an earlier step
// left it (see heldByRouter). So the user also pays a
// SETTLE of ETH that names the router as payer. The user
// pays all of its amount when the router holds no other
// ETH, and up to its amount otherwise. CONTRACT_BALANCE
// pays all the router holds, an amount the calldata does
// not state.
const routerHeldEth = heldByRouter.has(
// ETH, whichever payer it names. When the router may hold
// ETH from an earlier step, that ETH may pay some of the
// ETH that the user pays this step. The sender then pays
// at most the step's amount.
const routerMayHoldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
const ethFromRouter = (v4?.settledFromRouter ?? [])
.filter(
(s) =>
s.currency ===
"0x0000000000000000000000000000000000000000",
)
.map((s) => ({
currency: s.currency,
amount:
s.amount === CONTRACT_BALANCE ? null : s.amount,
}));
// What the user pays the step: what decodeV4Swap() says,
// and the ETH of each SETTLE from the router of a known
// amount when the router holds no other ETH.
const paidByUser = v4
? v4.paidByUser.concat(
routerHeldEth
? []
: ethFromRouter.filter((s) =>
present(s.amount),
),
)
: [];
if (v4) {
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
@@ -1896,10 +1863,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
commandNames[i] += " (could not be decoded)";
}
// The step's amount, if it sets the input side: what the
// user pays it of its input currency, or, when the user
// pays none of it, what its swap actions spend of it,
// whoever pays that.
const paidIn = paidByUser.find(
// user pays it of its input currency (see
// decodeV4Swap()), or, when the user pays none of it,
// what its swap actions spend of it, whoever pays that.
const paidIn = v4?.paidByUser.find(
(p) => p.currency === v4.tokenIn,
);
const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
@@ -1953,24 +1920,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined;
for (const p of paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
}
// A SETTLE of ETH from the router that `paidByUser` leaves
// out is a take of its own, even on the step that sets
// the input side. Either this decoder does not know its
// amount, or ETH an earlier step left in the router may
// pay part of that amount (see userPaid).
for (const p of ethFromRouter) {
if (!present(p.amount)) {
addUserPaid(p.currency, null);
} else if (routerHeldEth) {
userPaid.push({
token: p.currency,
amount: p.amount,
atMost: true,
});
if (v4) {
for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(
p.currency,
p.amount,
routerMayHoldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
}
}
if (!v4 || v4.settleUnread) {
@@ -2078,10 +2037,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The total is what the steps take of Token In: the amount of the
// step that set the input side, with each later take of that token
// added to it while both are quantities. A take that cannot be added,
// being of another token, coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
// `atMost` set, gets lines of its own. One whose amount is not known
// also leaves the total unknown, with no figure.
// being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. A take of at most its amount (see
// userPaid) is never added, and gets lines of its own too.
//
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
@@ -2097,14 +2057,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// it may take more. A later permit or batch, or a later step this
// decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A permit
// on the zero address never stands as the Amount figure, because
// Permit2 has no allowance of ETH. A step that may take from the
// sender what this decoder did not read leaves no figure, permit or
// not (see spentUnread()), and so do a permit or batch whose input it
// cannot read (see permitUnread()) and an allowance of Token In to
// another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step
// leaves both sides unknown (below).
// they read with no permit, and gets lines of its own below. A step
// that may take from the sender what this decoder did not read leaves
// no figure, permit or not (see spentUnread()), and so do a permit
// or batch whose input it cannot read (see permitUnread()) and an
// allowance of Token In to another spender (see toAnotherSpender). An
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,
(b) => commands[b & commandTypeMask],
@@ -2114,25 +2072,27 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) &&
permit.token !== "0x0000000000000000000000000000000000000000" &&
!permit.allowedToFail &&
permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know.
// { token, amount, atMost, recipient }. A null token is a step this
// decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of the step that set the input side
// counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
// The takes from the sender that `total` adds up, each a quantity, in
// the shape of `spentFromSender`. When there is no Amount line, each
// gets lines of its own (see below). The amount of a step that set
// the input side and is paid from the router counts in `total` but is
// not among them.
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
// whoever pays it. Stating none, it counts only when the user pays it
// some of its input token.
// some of its input token: paid from the router, it takes nothing
// from the sender.
const hasFirstTake =
inputEstablished && (present(inputAmount) || inputPaidByUser);
// The take of the step that set the input side, when the total
@@ -2141,7 +2101,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
@@ -2149,7 +2113,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount });
spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
@@ -2157,9 +2125,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (ofTokenIn && permitStands) continue;
if (
ofTokenIn &&
!t.atMost &&
typeof total === "bigint" &&
typeof t.amount === "bigint" &&
!t.atMost
typeof t.amount === "bigint"
) {
total += t.amount;
inTotal.push(t);
@@ -2262,9 +2230,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be
// compared with one. `atMost` marks an amount that is only the most a
// take may be (see userPaid).
const spentText = (amount, info, atMost) => {
// compared with one. `atMost` says the amount is a maximum (see
// userPaid).
const spentText = (amount, info, atMost = false) => {
if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
}
@@ -2304,19 +2272,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be
// too low. An allowance of the zero address is never one of Token In,
// because Permit2 has no allowance of ETH. With no Amount line, each
// take the total adds up gets lines of its own, first, so that every
// amount this decoder read is shown.
// too low. With no Amount line, each take the total adds up gets
// lines of its own, first, so that every amount this decoder read is
// shown.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) &&
toAnotherSpender.every(
(p) =>
p.token === "0x0000000000000000000000000000000000000000" ||
(present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken)),
present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken),
)
) {
const amount = spentText(inputAmount, inInfo);
@@ -2332,7 +2298,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets
// that one line alone.
// that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient;
// `MSG_SENDER` means the sender.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
@@ -2342,25 +2310,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
details.push(tokenMovedLine("Token moved", s.token, sources));
if (!present(s.recipient)) {
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
details.push({
label: "Moved to",
value: s.recipient,
address: s.recipient,
});
}
}
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, info).display,
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
});
details.push(tokenMovedLine("Token moved", info));
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
@@ -2390,15 +2366,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
continue;
}
const info = permit2TokenInfo(p.token, sources);
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, info).display,
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(tokenMovedLine("Allowance token", info));
details.push(tokenMovedLine("Allowance token", p.token, sources));
details.push({
label: "Allowance spender",
value: p.spender,
@@ -2456,7 +2432,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, sources);
const info = tokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
@@ -2464,7 +2440,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push(tokenMovedLine("Token moved into the router", info));
paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2481,7 +2459,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
tokenInfo(getAddress(token), sources),
getAddress(token),
sources,
),
);
}
+4 -4
View File
@@ -1,9 +1,9 @@
// The USD total of an address that holds something this build cannot price
// (issue #261).
//
// Only a token with a price feed has a price, so an address can hold real
// assets with no price attached. Summing what is priced and printing the result
// as the total says "$0.00" for an address holding nothing but unpriced tokens —
// Prices exist for the top 25 tokens only, so an address can hold real assets
// with no price attached. Summing what is priced and printing the result as
// the total says "$0.00" for an address holding nothing but unpriced tokens —
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
// direction that matters. The two are separate facts here, the same way an
// absent holders_count is not a count of zero.
@@ -63,7 +63,7 @@ const PARTLY_PRICED = {
beforeEach(() => {
clearPrices();
prices.ETH = 2000;
prices[USDC] = 1;
prices.USDC = 1;
state.wallets = [];
state.trackedTokens = [];
state.showZeroBalanceTokens = false;
-51
View File
@@ -6,10 +6,6 @@
// 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.
//
// The last test covers a balance refresh by the background that is saved while
// a switch in Settings waits for the lock
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
const {
bootPopup,
@@ -163,50 +159,3 @@ test("a refresh that returns after the background switched the network writes no
expectNothingStored(page);
});
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// token balances, the values that the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
await page.click("btn-settings");
// The background takes the lock first and holds it until the test calls
// `letGo()`. It then saves balances as backgroundRefresh() in
// src/background/index.js does.
let letGo;
const refreshSave = background.updateState(async (fresh) => {
await new Promise((resolve) => {
letGo = resolve;
});
fresh.wallets[0].addresses[0].balance = "7.0";
fresh.wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
fresh.lastBalanceRefresh = Date.now();
});
await page.settle();
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
letGo();
await refreshSave;
await switching;
await page.settle();
expectNothingStored(page);
});
+3 -4
View File
@@ -226,7 +226,6 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const PASSWORD = "correct horse battery staple";
function el(id) {
@@ -319,7 +318,7 @@ async function switchReopenAndGoBack(reviewedOn, switchedTo) {
setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({});
prices.ETH = 2000;
prices[USDC] = 1;
prices.USDC = 1;
mockNode.asked = [];
global.fetch.mockClear();
goBack();
@@ -642,7 +641,7 @@ describe.each([
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
mockNode.tokenUnits[reviewedOn] = 50000000n;
await review(reviewedOn, {
token: USDC,
token: TOKEN,
tokenSymbol: "USDC",
tokenDecimals: 6,
amount: "10",
@@ -653,7 +652,7 @@ describe.each([
"ERC-20 token transfer (USDC)",
);
expect(el("confirm-token-contract").innerHTML).toContain(
`href="${explorerUrl}/address/${USDC}"`,
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
+1 -1
View File
@@ -153,7 +153,7 @@ describe("the balance warning on the removal confirmation", () => {
test("the USD total is shown when prices are known", () => {
prices.ETH = 2000;
prices[USDC] = 1;
prices.USDC = 1;
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
});
+1 -13
View File
@@ -43,15 +43,12 @@ const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI;
const STUB_BLOCK_NUMBER = 21000000;
// A 32-byte zero word, returned for every eth_call but a price feed's
// latestRoundData() (see rpcResult() below). It is what makes ethers'
// A 32-byte zero word, returned for every eth_call. It is what makes ethers'
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
// throw there reaches the console through src/shared/log.js, which fails the
// run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
// One ETH, so the popup's balance lines render something and the wallet does
// not look empty on the approval screen.
const STUB_BALANCE_WEI = 10n ** 18n;
@@ -114,15 +111,6 @@ function rpcResult(req, state) {
return latestBlock();
}
// A price feed's latestRoundData() gets five zero words, as in network.js:
// a price of zero, which the wallet counts as no price.
const data = String(
(req.params && req.params[0] && req.params[0].data) || "",
);
if (method === "eth_call" && data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
return "0x" + "0".repeat(64 * 5);
}
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
return RPC_RESULTS[method];
}
+6 -9
View File
@@ -251,13 +251,8 @@ const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
// On any contract, a latestRoundData() call is answered with five zero words.
// That is a round with a price of zero, which the wallet counts as no
// price. So no screen shows a USD value beside the amounts the tests compare.
//
// Every other eth_call answers with a zero word except decimals(), symbol(),
// Every eth_call still answers with a zero word except decimals(), symbol(),
// name() and balanceOf() on the stub token. The wallet reads decimals() back
// at signing time to compare with the scale the confirmation screen rendered
// (issue #305). Adding the token by its contract address reads the first three
@@ -278,9 +273,6 @@ function ethCallResult(req, opts) {
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
return "0x" + "0".repeat(64 * 5);
}
if (to !== STUB_TOKEN.address) return ZERO_WORD;
if (data.startsWith(SELECTOR_DECIMALS)) {
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
@@ -772,6 +764,11 @@ async function installNetworkStubs(ctx, opts) {
}
}
// CoinDesk price tick
if (url.hostname.endsWith("coindesk.com")) {
return jsonResponse(route, { Data: {} });
}
// The interception canary's own request. Answered with nothing: what
// is being observed is that it arrived here at all.
if (url.href === WORKER_PROBE_URL) {
-493
View File
@@ -1,493 +0,0 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
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"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
-265
View File
@@ -253,271 +253,6 @@ test("a switch back in Settings after a follow whose write failed is stored", as
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the
// URL is stored, the background saves a switch to Sepolia. Another write holds
// the lock, so the popup's follow of that switch and the URL's save both wait
// for the lock. Either may take it first. The URL is stored for neither
// network. The flash message says that the network changed.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc", first: "follow" },
{ button: "btn-save-rpc", field: "settings-rpc", first: "save" },
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "follow",
},
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "save",
},
])(
"$button saves nothing when the network changes before the URL is stored ($first first)",
async ({ button, field, first }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks. The RPC check reads
// Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The click handler runs until its save waits for the lock.
const tapSave = async () => {
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
return saving;
};
let saving;
if (first === "save") saving = await tapSave();
await backgroundSwitch(page, "sepolia");
if (first === "follow") saving = await tapSave();
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The popup's follow of the background's switch to Sepolia has set `state` to
// Sepolia. The follow's write has not returned yet, so Settings still shows
// Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum
// Mainnet, so the save stores the URL for neither network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing when tapped while the follow's write waits",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
const { set } = page.storage.local;
const write = set.getMockImplementation();
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
// The background's write lands; the next, the follow's, waits.
set.mockImplementationOnce(write).mockImplementationOnce(
async (items) => {
await writeHeld;
await write(items);
},
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(page.value("settings-network")).toBe("mainnet");
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseWrite();
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for
// the node's answer, the popup follows the background's switch to Sepolia. The
// node answers with Sepolia's chain id. So the check refuses the node for
// Ethereum Mainnet, and nothing is stored.
test("the RPC check compares the answer with the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://sepolia-node.example.invalid";
page.node("settings-rpc").value = url;
const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"Wrong network: expected Ethereum Mainnet.",
);
});
// The user taps the Blockscout "Save" on Ethereum Mainnet. While the check
// waits for Blockscout's answer, the popup follows the background's switch to
// Sepolia. The URL is for Ethereum Mainnet, so nothing is stored.
test("the Blockscout save is for the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://own-explorer.example.invalid/api/v2";
page.node("settings-blockscout").value = url;
const saving = page
.node("btn-save-blockscout")
.listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
expect(page.value("settings-network")).toBe("sepolia");
answer({ ok: true });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
});
// Settings is open when the network changes to Sepolia. The popup follows the
// background's switch, or the user picks Sepolia in the network selector. Each
// "Save" after the switch stores its URL for Sepolia.
test.each([
["a follow", (page) => backgroundSwitch(page, "sepolia")],
[
"the network selector",
async (page) => {
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
},
],
])(
"after a switch to Sepolia by %s, Settings saves each URL for Sepolia",
async (_how, switchToSepolia) => {
const page = await bootOnAddress();
await page.click("btn-settings");
await switchToSepolia(page);
// This answer passes both endpoint checks on Sepolia. The RPC check
// reads Sepolia's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.sepolia.chainId }),
});
const rpcUrl = "https://own-node.example.invalid";
const blockscoutUrl = "https://own-explorer.example.invalid/api/v2";
page.node("settings-rpc").value = rpcUrl;
await page.click("btn-save-rpc");
expect(page.text("flash-msg")).toBe("Saved.");
page.node("settings-blockscout").value = blockscoutUrl;
await page.click("btn-save-blockscout");
expect(page.text("flash-msg")).toBe("Saved.");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.rpcUrl).toBe(rpcUrl);
expect(stored.blockscoutUrl).toBe(blockscoutUrl);
},
);
// The user picks Sepolia in Settings, but the switch's write fails. The page is
// then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save"
// after the failed switch stores nothing for either network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing after a switch in Settings whose write failed",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
page.storage.local.set.mockRejectedValueOnce(
new Error("quota exceeded"),
);
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) {
await expect(fn()).rejects.toThrow("quota exceeded");
}
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
page.node(field).value = url;
await page.click(button);
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// 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.
-272
View File
@@ -1,272 +0,0 @@
// USD prices are read from Chainlink price feeds on mainnet, through the RPC
// endpoint (https://git.eeqj.de/sneak/AutistMask/issues/525).
//
// The real refreshPrices() runs against a fake JSON-RPC node, answered through
// ethers' own request hook. The hook also records the body of every HTTP
// request, so a test can count them.
// prices.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, FetchRequest } = require("ethers");
const {
prices,
refreshPrices,
clearPrices,
getPrice,
getAddressValue,
} = require("../src/shared/prices");
const { state } = require("../src/shared/state");
const {
TOKENS,
ETH_USD_FEED,
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
} = require("../src/shared/tokenList");
const RPC_URL = "https://rpc.example.invalid";
const NOW = Date.UTC(2026, 9, 27, 12, 0, 0);
const NOW_SECONDS = NOW / 1000;
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const LINK = "0x514910771af9ca656af840dff83e8264ecf986ca";
const DAI = "0x6b175474e89094c44da98b954eedeac495271d0f";
// A token at an address of its own that calls itself USDC.
const FAKE_USDC = "0x9999999999999999999999999999999999999999";
const ETH_FEED = ETH_USD_FEED.address.toLowerCase();
const USDC_FEED = PRICE_FEED_BY_ADDRESS.get(USDC).address.toLowerCase();
const LINK_FEED = PRICE_FEED_BY_ADDRESS.get(LINK).address.toLowerCase();
const DAI_FEED = PRICE_FEED_BY_ADDRESS.get(DAI).address.toLowerCase();
// What each feed answers, by its lowercased address: the price in the feed's
// own units and the time it was written. A feed that is not here makes the
// call fail.
let rounds;
// The parsed body of every HTTP request the node received.
let requests;
let logged;
function round(answer, updatedAt = NOW_SECONDS) {
return { answer, updatedAt };
}
// One JSON-RPC answer, to a latestRoundData() call.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
const answer = rounds[req.params[0].to.toLowerCase()];
if (!answer) {
return {
...envelope,
error: { code: -32000, message: "fixture: read failed" },
};
}
return {
...envelope,
result: AbiCoder.defaultAbiCoder().encode(
["uint80", "int256", "uint256", "uint256", "uint80"],
[1, answer.answer, answer.updatedAt, answer.updatedAt, 1],
),
};
}
// The feed addresses one HTTP request read, sorted.
function feedsRead(body) {
return [body]
.flat()
.map((req) => req.params[0].to.toLowerCase())
.sort();
}
// One address that holds `held`, a list of [contract address, symbol].
function holdTokens(held) {
state.wallets = [
{
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: held.map(([address, symbol]) => ({
address,
symbol,
balance: "10.0",
})),
},
],
},
];
}
beforeEach(() => {
clearPrices();
state.networkId = "mainnet";
state.rpcUrl = RPC_URL;
state.wallets = [];
state.trackedTokens = [];
rounds = { [ETH_FEED]: round(2000n * 10n ** 8n) };
requests = [];
logged = [];
jest.spyOn(Date, "now").mockReturnValue(NOW);
for (const level of ["error", "warn"]) {
jest.spyOn(console, level).mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
}
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
requests.push(body);
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
jest.restoreAllMocks();
clearPrices();
});
describe("refreshPrices", () => {
test("reads ETH and each held or tracked token that has a feed in one HTTP request", async () => {
holdTokens([
[USDC, "USDC"],
[LINK, "LINK"],
]);
state.trackedTokens = [{ address: DAI, symbol: "DAI", decimals: 18 }];
rounds[USDC_FEED] = round(10n ** 8n);
rounds[LINK_FEED] = round(1150000000n);
rounds[DAI_FEED] = round(99990000n);
await refreshPrices();
expect(requests).toHaveLength(1);
expect(feedsRead(requests[0])).toEqual(
[ETH_FEED, USDC_FEED, LINK_FEED, DAI_FEED].sort(),
);
expect(getPrice("ETH")).toBe(2000);
expect(getPrice(USDC)).toBe(1);
expect(getPrice(LINK)).toBe(11.5);
expect(getPrice(DAI)).toBe(0.9999);
});
test("reads again only once 5 minutes have passed", async () => {
await refreshPrices();
Date.now.mockReturnValue(NOW + 299999);
await refreshPrices();
expect(requests).toHaveLength(1);
Date.now.mockReturnValue(NOW + 300000);
rounds[ETH_FEED] = round(2100n * 10n ** 8n, NOW_SECONDS + 300);
await refreshPrices();
expect(requests).toHaveLength(2);
expect(getPrice("ETH")).toBe(2100);
});
test("on Sepolia, reads nothing and keeps no price", async () => {
await refreshPrices();
expect(getPrice("ETH")).toBe(2000);
state.networkId = "sepolia";
await refreshPrices();
expect(requests).toHaveLength(1);
expect(Object.keys(prices)).toEqual([]);
});
});
describe("pairing by contract address", () => {
test("a token that copies a bundled token's symbol gets no price", async () => {
holdTokens([
[USDC, "USDC"],
[FAKE_USDC, "USDC"],
]);
rounds[USDC_FEED] = round(10n ** 8n);
await refreshPrices();
expect(feedsRead(requests[0])).toEqual([ETH_FEED, USDC_FEED].sort());
expect(getPrice(USDC)).toBe(1);
expect(getPrice(FAKE_USDC)).toBeNull();
// 1 ETH and 10 real USDC are priced; the 10 look-alikes are not.
expect(getAddressValue(state.wallets[0].addresses[0])).toEqual({
usd: 2010,
partial: true,
});
});
});
describe("freshness", () => {
test("a reading older than its feed's heartbeat plus 30 minutes is no price", async () => {
const heartbeatAgo =
NOW_SECONDS - PRICE_FEED_BY_ADDRESS.get(USDC).heartbeat;
holdTokens([[USDC, "USDC"]]);
rounds[USDC_FEED] = round(10n ** 8n, heartbeatAgo - 1800 - 1);
await refreshPrices();
expect(getPrice(USDC)).toBeNull();
clearPrices();
rounds[USDC_FEED] = round(10n ** 8n, heartbeatAgo - 1800);
await refreshPrices();
expect(getPrice(USDC)).toBe(1);
});
test.each([0n, -1n])("an answer of %s is no price", async (answer) => {
rounds[ETH_FEED] = round(answer);
await refreshPrices();
expect(getPrice("ETH")).toBeNull();
});
});
describe("a failed read", () => {
test("leaves the token with no price and is logged with its error", async () => {
holdTokens([[USDC, "USDC"]]);
rounds[USDC_FEED] = round(10n ** 8n);
await refreshPrices();
expect(getPrice(USDC)).toBe(1);
Date.now.mockReturnValue(NOW + 300000);
delete rounds[USDC_FEED];
await refreshPrices();
expect(getPrice(USDC)).toBeNull();
expect(getPrice("ETH")).toBe(2000);
expect(
logged.some(
(line) =>
line.includes("price feed read failed") &&
line.includes(USDC),
),
).toBe(true);
});
test("an HTTP error is logged with its status", async () => {
FetchRequest.registerGetUrl(async () => ({
statusCode: 401,
statusMessage: "Unauthorized",
headers: { "content-type": "text/plain" },
body: Buffer.from("API key required"),
}));
await refreshPrices();
expect(getPrice("ETH")).toBeNull();
expect(logged.some((line) => line.includes("401"))).toBe(true);
});
});
describe("the feed table", () => {
test("names each token by its address as the bundled list spells it", () => {
const listed = new Set(TOKENS.map((t) => t.address));
const unlisted = Object.keys(USD_PRICE_FEEDS).filter(
(address) => !listed.has(address),
);
expect(unlisted).toEqual([]);
});
});
-49
View File
@@ -1,49 +0,0 @@
// Another page stores a different RPC or Blockscout URL for the same network.
// Then the user taps "Save" in Settings on this page, on the URL that this page
// loaded. The URL on screen is stored, and the flash message reads "Saved."
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
// popup over a stored profile on Ethereum Mainnet.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
afterEach(() => {
cleanupPopup();
});
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores the URL on screen after another page stored a different one",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const url = page.value(input);
// This write sends this page no storage change event.
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
[field]: "https://other-page.example.invalid",
});
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
// reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.click(button);
expect(page.storage.read("autistmask")[field]).toBe(url);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
-26
View File
@@ -23,9 +23,6 @@
// version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working.
const fs = require("fs");
const path = require("path");
const {
bootPopup,
cleanupPopup,
@@ -151,29 +148,6 @@ describe("the destructive reset on the recovery screen", () => {
});
});
// A key or xprv wallet has no recovery phrase, so the erase warning names all
// three backups (https://git.eeqj.de/sneak/AutistMask/issues/593). The boot
// harness holds no static text, so this reads the markup itself.
describe("the erase warning on the recovery screen", () => {
test("names the recovery phrase, the private key and the extended private key", () => {
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
const start = html.indexOf('id="view-state-recovery"');
const end = html.indexOf('id="btn-state-recovery-reset"', start);
const screenText = html
.slice(start, end)
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ");
expect(screenText).toContain(
"If you kept a copy of a wallet's recovery phrase, private key or " +
"extended private key, you can add that wallet again.",
);
});
});
describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
// on the wallet list with the last good balances, and only a reopen
-72
View File
@@ -177,78 +177,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
expect(onSepolia).not.toContain("DAI");
});
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch's save writes only the token lists that this page changed.
// So DAI stays in `trackedTokens`, the active network's list.
test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
+339 -344
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
];
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
// the user to `router`.
function decodeSteps(commands, inputs, router = ROUTER_ADDR) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
return uniswap.decode(data, router, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
@@ -2424,6 +2424,335 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that
// sets the input side and pays its WETH to anyone but the router
// names that recipient on its Moved to line. On next each row read
// The swap step that spends it.
const ONE_ETH = 1000000000000000000n;
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
// The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`.
const wrapLines = (to) => [
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", to],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines("The sender"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V3_600,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a
// SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The two
// rows marked "reads as on next" read the same on next. On next every
// other row added that ETH to the Amount line, or read it as an exact
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user.
const v4EthForUsdc = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, 0n, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
@@ -3310,334 +3639,6 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
});
// https://git.eeqj.de/sneak/AutistMask/issues/660 — Permit2 has no
// allowance of ETH, so a permit on the zero address reads as one on a
// token nothing knows. On next at 53551c6 such a permit's Allowance
// token line read ETH. A permit of 1 wei of the zero address before a
// V4 step of 5 ETH made the Amount line read 0.000000000000000001 ETH.
// With that permit to another spender and a SETTLE the router pays, no
// line gave the 5 ETH.
describe("a permit on the zero address", () => {
const FIVE_ETH = 5000000000000000000n;
// The lines of a permit of `amount` base units of the zero
// address for `spender`.
const zeroAddressLines = (amount, spender) => [
[
"Permit2 allowance",
amount + " base units (decimals unknown)",
],
["Allowance token", ZERO_ADDR],
["Allowance spender", getAddress(spender)],
];
// A V4 swap of 5 ETH for USDC. The user pays its SETTLE when
// `payerIsUser` is true; otherwise the router pays it from the
// transaction's value.
const v4FiveEth = (payerIsUser) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
FIVE_ETH,
1n,
),
settle(ZERO_ADDR, FIVE_ETH, payerIsUser),
encodeV4TakeAll(USDC_ADDR, 1n),
],
);
test("a PERMIT2_PERMIT on the zero address names the address", () => {
const result = decodeSteps(
[0x00, 0x0a],
[
V3_600,
encodePermit2(
ZERO_ADDR,
1000000000000000000n,
ROUTER_ADDR,
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...zeroAddressLines("1000000000000000000", ROUTER_ADDR),
]);
expect(detail(result, "Allowance token").address).toBe(
ZERO_ADDR,
);
});
test("a PERMIT2_PERMIT_BATCH entry on the zero address names the address", () => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_600],
[ZERO_ADDR, 1000000000000000000n],
],
ROUTER_ADDR,
),
V3_600,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...USDT_600_LINES,
...zeroAddressLines("1000000000000000000", ROUTER_ADDR),
]);
});
test("a PERMIT2_PERMIT of 1 wei of the zero address, then a V4 step of 5 ETH the user settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x0a, 0x10],
[
encodePermit2(ZERO_ADDR, 1n, ROUTER_ADDR),
v4FiveEth(true),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1", ROUTER_ADDR),
]);
});
test("a PERMIT2_PERMIT of 1 wei of the zero address to another spender, then a V4 step of 5 ETH the router settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x0a, 0x10],
[
encodePermit2(ZERO_ADDR, 1n, OTHER_ADDR),
v4FiveEth(false),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1", OTHER_ADDR),
]);
});
test("a PERMIT2_PERMIT_BATCH entry of 1 wei of the zero address to another spender, then a V4 step of 5 ETH the router settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x03, 0x10],
[
encodePermit2Batch([[ZERO_ADDR, 1n]], OTHER_ADDR),
v4FiveEth(false),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1", OTHER_ADDR),
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/685 — Permit2 moves no
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
// with an ETH figure.
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
const MOVED_AMOUNT =
"1000000000000000000 base units (decimals unknown)";
test("to another address names the address", () => {
const result = decodeSteps(
[0x00, 0x02],
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
});
test("into the router names the address", () => {
const result = decodeSteps(
[0x02, 0x00],
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
);
expect(detail(result, "Paid to").value).toBe(
"The router, " +
MOVED_AMOUNT +
" from the sender (later steps may not spend all of it)",
);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: ZERO_ADDR,
address: ZERO_ADDR,
isToken: true,
});
});
// The V4 step's SETTLE names the user as payer and states no
// amount. The step's lines say that the ETH amount is not known.
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
const v4EthExactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
590000000n,
ONE_WETH,
),
encodeV4Settle(ZERO_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x02],
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE
// of ETH from the router's own ETH, whichever payer the SETTLE names,
// and the router holds the transaction's value. On next at 36b2002 a
// SETTLE of 1 ETH that names the router as payer gave no ETH line.
describe("a V4 SETTLE of ETH that names the router as payer", () => {
const ONE_ETH = 1000000000000000000n;
// A V4 swap of `amount` of ETH for USDC. The SETTLE names the
// router as payer.
const v4EthFromRouter = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, amount, false),
encodeV4TakeAll(USDC_ADDR, 1n),
],
);
test("after a V3 exact-in of 600 USDT, shows the 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, v4EthFromRouter(ONE_ETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// The UNWRAP_WETH leaves ETH in the router. The SETTLE may spend
// that ETH before the transaction's value.
test("after an UNWRAP_WETH into the router, shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x00, 0x0c, 0x10],
[
encodeV3SwapExactIn(ROUTER_ADDR, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
test("after a WRAP_ETH of 0.5 ETH, adds the SETTLE's 0.5 ETH to the Amount line", () => {
const result = decodeSteps(
[0x0b, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
v4EthFromRouter(ONE_ETH / 2n),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1.0000 ETH"],
]);
});
// The WRAP_ETH sets Token In to ETH, and the UNWRAP_WETH leaves
// ETH in the router. The SETTLE may spend that ETH before the
// transaction's value, so its 1 ETH never joins the Amount line.
test("after a WRAP_ETH and an UNWRAP_WETH into the router, keeps the Amount line and shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x0b, 0x0c, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "0.5000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// A SETTLE of CONTRACT_BALANCE pays all the router holds, an
// amount the calldata does not state.
test("of CONTRACT_BALANCE, on the step that sets the input side, shows an unknown amount moved from the sender", () => {
const result = decodeSteps(
[0x10],
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
ONE_ETH,
1n,
),
settle(ZERO_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
encodeV4TakeAll(USDC_ADDR, 1n),
],
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
@@ -4380,9 +4381,10 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
});
// A zero balance check sets neither Token Out nor the minimum. The route
// pays only the WRAP_ETH's WETH, to the router, where it stays.
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
// With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing. No step pays anyone, so the
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[
@@ -4395,16 +4397,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
+14 -89
View File
@@ -688,62 +688,6 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]);
});
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still 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),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// 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(
@@ -1717,18 +1661,9 @@ describe("any token left in the router is not received", () => {
),
min: "1990.0000 USDC",
},
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — the SETTLE spends all
// the router's ETH, the transaction's value included, so the sender pays
// an amount the calldata does not state.
test("a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router reads as a swap paid to the user", () => {
const result = decode(
execute(
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v4Swap(
@@ -1737,23 +1672,12 @@ describe("any token left in the router is not received", () => {
takeAll(USDC),
),
),
);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Moved from the sender",
"Token moved",
"Moved to",
"Token Out",
"Min. received",
"Steps",
"Deadline",
]);
expect(detail(result, "Moved from the sender").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
min: "1990.0000 USDC",
},
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
});
@@ -2876,16 +2800,17 @@ describe("the allow-revert flag", () => {
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
expect(detail(withFlag, "Min. received")).toBeUndefined();
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
+3 -3
View File
@@ -103,7 +103,7 @@ describe("balanceLine", () => {
});
test("an unknown quantity produces no fiat figure, a zero one does", () => {
prices[NOVEL] = 3;
prices.NOVEL = 3;
const unknown = balanceLine("NOVEL", null, 3, NOVEL);
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
// A price times an unknown quantity is not $0.00: that is the same
@@ -162,7 +162,7 @@ describe("getAddressValue", () => {
// make it incomplete.
test("an unknown balance makes the total partial, a zero balance does not", () => {
prices.ETH = 2000;
prices[NOVEL] = 3;
prices.NOVEL = 3;
const unknown = getAddressValue(address(null));
const zero = getAddressValue(address("0.0"));
expect(unknown).not.toEqual(zero);
@@ -172,7 +172,7 @@ describe("getAddressValue", () => {
test("an unknown balance is not priced as zero of the token", () => {
prices.ETH = 2000;
prices[NOVEL] = 3;
prices.NOVEL = 3;
// The same row with a real quantity of 10 is worth $30. Neither that
// figure nor a confident $0.00 may be stated for the unknown one.
expect(getAddressValue(address("10.0"))).toEqual({