1 Commits
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
19 changed files with 646 additions and 1119 deletions
+120 -114
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 only to the Ethereum RPC endpoint. The user can change that endpoint in
Settings. The endpoint defaults to a public node. Settings. The endpoint defaults to a public node.
Out of the box the extension contacts one service, the configured RPC node. The Out of the box the extension contacts two services:
wallet uses the node for:
- blockchain interactions - the configured RPC node, for blockchain interactions
- USD prices on mainnet, read from Chainlink price feed contracts - 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 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: 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 constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only) ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule holders.js — holder-count parsing and the low-holder rule
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 scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan", storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
read from storage for the requests those settings gate read from storage for the requests those settings gate
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink tokenList.js — top ERC-20 tokens by market cap (hardcoded)
price feeds (hardcoded)
transactions.js — tx history fetching + anti-poisoning filters transactions.js — tx history fetching + anti-poisoning filters
uniswap.js — Uniswap Universal Router calldata decoder uniswap.js — Uniswap Universal Router calldata decoder
vault.js — password-based encryption via libsodium 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 the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their `Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows
its `amountOut`, the exact amount it buys. 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 - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -1372,10 +1373,10 @@ whole swap undecoded.
#### Partial USD totals #### Partial USD totals
Only a token with a Chainlink price feed has a price, so an address can hold Prices are fetched for the top 25 tokens only, so an address can hold assets the
assets the extension has no price for. Worth zero and worth an unknown amount extension has no price for. Worth zero and worth an unknown amount are different
are different facts and are never collapsed into one number. `getAddressValue()` facts and are never collapsed into one number. `getAddressValue()` in
in `src/shared/prices.js` returns `{ usd, partial }` — the value of the priced `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 holdings, and whether an unpriced holding was left out of it — and every screen
renders it through `formatAddressTotal()`, so the wording cannot drift: renders it through `formatAddressTotal()`, so the wording cannot drift:
@@ -2821,9 +2822,6 @@ view would leave a wallet one click from deletion.
- "Export Saved Data" button, and the read-only text box it fills - "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 - 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 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 - A text input asking for `ERASE MY WALLET` to be typed back
- Error line - Error line
- "Erase Saved Data" button - "Erase Saved Data" button
@@ -2855,9 +2853,9 @@ view would leave a wallet one click from deletion.
### External Services ### External Services
AutistMask is not a fully self-contained offline tool. By default it contacts AutistMask is not a fully self-contained offline tool. By default it sends the
only the Ethereum RPC endpoint. USD prices are read from Chainlink price feed user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price
contracts through that endpoint. 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 Two more services are sent none of the user's data until the user turns on a
setting for each: setting for each:
@@ -2881,18 +2879,21 @@ Contacted by default:
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`), estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. Every ENS lookup is made through the RPC endpoint alone. So a name 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 does not resolve when its resolver says the name is resolved off-chain. The
mainnet the extension also reads USD prices from Chainlink price feed default endpoint is a public RPC (configurable by the user to any endpoint
contracts (`latestRoundData`). The prices of ETH and of each held or tracked they prefer, including a local node). By default the extension talks to
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
`https://ethereum-rpc.publicnode.com`. `https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call - **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted parameters. The RPC endpoint can see all on-chain queries and submitted
transactions. 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: Sent the user's data only while its setting is on:
- **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is - **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is
@@ -3068,13 +3069,14 @@ hardcoded test phrase.
At the raised log level the console also shows the wallet's addresses with their 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 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 request made through `debugFetch` in `src/shared/log.js` (the explorer, the
calls a site makes, and the endpoint checks in settings) and for its response. A price feed, the RPC calls a site makes, and the endpoint checks in settings) and
request is logged by its HTTP method, the origin of its URL (scheme, host and for its response. A request is logged by its HTTP method, the origin of its URL
port) and, for a JSON-RPC call, the method name; the balance refresh, the token (scheme, host and port) and, for a JSON-RPC call, the method name; the balance
lookup and a failed endpoint check in settings name the endpoint by its origin refresh, the token lookup and a failed endpoint check in settings name the
too. The URL's path and query string, where RPC providers put API keys, any user endpoint by its origin too. The URL's path and query string, where RPC providers
name and password in it, and the request body are never logged. put API keys, any user name and password in it, and the request body are never
logged.
### Key Decisions ### Key Decisions
@@ -3477,46 +3479,48 @@ Currently supported:
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the
rest taken to another address, reads `5000.0000 USDT`. A later step paid from rest taken to another address, reads `5000.0000 USDT`. V4 pays a `SETTLE` of
the router spends what the router holds and takes nothing, and so does a V2 ETH from the router's own ETH, whichever payer it names. So when the router
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is may hold ETH from an earlier step (see above), the ETH that the user pays a V4
added to the total while both are quantities: a V3 exact-in of 600 USDT and a step gets lines of its own (see below). A later step paid from the router
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When spends what the router holds and takes nothing, and so does a V2 exact-in
the decoder does not know a take's amount, the total is not known and there is whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in
`Unlimited` included, stands as the `Amount` figure in place of the total, of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder
which it does only in the plain shape of Uniswap's own routes: it comes before does not know a take's amount, the total is not known and there is no `Amount`
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited`
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, included, stands as the `Amount` figure in place of the total, which it does
the router may not let it fail, no step before it takes its token, a only in the plain shape of Uniswap's own routes: it comes before the step that
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and
and the decoder reads every step after it in full. Permit2 then lets the there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may
router take no more of the token than that amount, so the permit covers every not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM`
take of it. A step before the permit takes under whatever allowance the router of it included, or a token the decoder does not know, and the decoder reads
already had, so it may take more than the permit's amount, which would then every step after it in full. Permit2 then lets the router take no more of the
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of token than that amount, so the permit covers every take of it. A step before
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 the permit takes under whatever allowance the router already had, so it may
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A take more than the permit's amount, which would then read lower than what
later permit or batch may raise what Permit2 lets the router take, and so may leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the
a step the decoder does not read in full: a command the decoder does not read router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000
(listed at the end of this section), a step whose input it cannot read, in USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before batch may raise what Permit2 lets the router take, and so may a step the
the step that sets the input side or after it, leaves `Token In` and the decoder does not read in full: a command the decoder does not read (listed at
`Amount` line as they read with no permit, unless it lets a spender other than the end of this section), a step whose input it cannot read, in whole or in
the router take `Token In` (see below): a permit of 5,000 DAI to another part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with that sets the input side or after it, leaves `Token In` and the `Amount` line
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI as they read with no permit, unless it lets a spender other than the router
to the router. A permit on the zero address never stands as the `Amount` take `Token In` (see below): a permit of 5,000 DAI to another address and then
figure, because Permit2 has no allowance of ETH. When a permit's amount stands a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`,
and the step that sets the input side spends a token other than the permit's, and so do that exact-in and then a permit of 5,000 DAI to the router. When a
or one the decoder does not know, that step's amount gets lines of its own, permit's amount stands and the step that sets the input side spends a token
unless it states no amount and the user pays it none of its input token. Each other than the permit's, or one the decoder does not know, that step's amount
take the total does not count gives three lines after the `Amount` line (after gets lines of its own, unless it states no amount and the user pays it none of
`Token In` when there is none): one of a token other than `Token In`; one of its input token. Each take the total does not count gives three lines after
`Token In` after an amount that is not a quantity (a V4 open delta, the `Amount` line (after `Token In` when there is none): one of a token other
`ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated than `Token In`; one of `Token In` after an amount that is not a quantity (a
amount, as of a V4 exact-out step); and one whose amount the decoder does not V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line,
know. When there is no `Amount` line, each take the total counts gets them 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 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 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 (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
@@ -3538,22 +3542,26 @@ Currently supported:
its amount, worded as the `Amount` line words one, or its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it; `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. For ETH that the user
does not know gives the `Moved from the sender` line alone. A step may also pays a V4 step when the router may hold ETH, `Moved from the sender` reads
take from the user's wallet what the decoder did not read: a V4 step whose `Up to <amount>` for a known amount. For a `WRAP_ETH` that pays its WETH to
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the anyone but the router, `Moved to` names that recipient instead: `The sender`
router still runs; a V2 or V3 exact-in or exact-out step or a for `address(1)`, and otherwise the address. A token the decoder does not know
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose gives the `Moved from the sender` line alone. A step may also take from the
path is shorter than one token address or has bytes after its last hop; every user's wallet what the decoder did not read: a V4 step whose input or one of
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands; whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs;
and every step it fails on. Each such step gives one `Moved from the sender` a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input
line among those, reading `Unknown (could not be decoded)`. That line, or any it cannot read, such as a V3 step whose path is shorter than one token address
line for a token the decoder does not know, leaves no `Amount` line, whose or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the `EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such
permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into step gives one `Moved from the sender` line among those, reading
the router, in step order, gives three lines: `Moved from the sender`, the `Unknown (could not be decoded)`. That line, or any line for a token the
amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the decoder does not know, leaves no `Amount` line, whose figure might be too low,
token's address as the `Token In` line does; and `Moved to`, which reads and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
@@ -3568,32 +3576,30 @@ Currently supported:
amount stands gives none: the `Amount` line gives its amount, `Token In` its 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` 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 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 three lines, in step order with those of each `PERMIT2_PERMIT`. A
allowance of the zero address, `Permit2 allowance` gives the amount in base `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot
units and `Allowance token` gives the zero address. A `PERMIT2_PERMIT` or read, such as one whose token or spender word has a nonzero top byte, may let
`PERMIT2_PERMIT_BATCH` whose input the decoder cannot read, such as one whose any spender take any amount of any token: it gives one `Permit2 allowance`
token or spender word has a nonzero top byte, may let any spender take any line alone, reading `Unknown (could not be decoded)`. These lines change no
amount of any token: it gives one `Permit2 allowance` line alone, reading `Paid to` line: a permit or batch pays nobody. But code that a step runs, such
`Unknown (could not be decoded)`. These lines change no `Paid to` line: a as a V4 pool's hook or a token's own code, can call a spender other than the
permit or batch pays nobody. But code that a step runs, such as a V4 pool's router, which can then take up to its allowance from the user's wallet before
hook or a token's own code, can call a spender other than the router, which the transaction ends. So an allowance to any spender but the router, or a
can then take up to its allowance from the user's wallet before the permit or batch the decoder cannot read, makes the minimum read
transaction ends. So an allowance to any spender but the router, or a permit `Min. paid out`, and when its token is `Token In`, or the decoder does not
or batch the decoder cannot read, makes the minimum read `Min. paid out`, and know its token or `Token In`, it leaves no `Amount` line, whose figure might
when its token is `Token In`, or the decoder does not know its token or be too low: a permit of 5,000 USDT to another address and then a V3 exact-in
`Token In`, it leaves no `Amount` line, whose figure might be too low: a of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its
permit of 5,000 USDT to another address and then a V3 exact-in of 600 USDT own. A step goes unread when its recipient does not decode as an address, when
read with no `Amount` line, and the 600 USDT gets lines of its own. A step it is a V3 swap step whose path is shorter than one token address or has bytes
goes unread when its recipient does not decode as an address, when it is a V3 after its last hop, or when it is a command the decoder does not read: every
swap step whose path is shorter than one token address or has bytes after its command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`,
last hop, or when it is a command the decoder does not read: every command `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `PERMIT2_TRANSFER_FROM`, `V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
`V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, (`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`PERMIT2_TRANSFER_FROM_BATCH`, them), or a type its release names no command for.
`EXECUTE_SUB_PLAN` and the NFT commands among them), or a type its release
names no command for.
### Non-Goals Forever ### 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 ## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does 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 not have a backend server. Out of the box it sends your data only to the
endpoint. USD prices are read from Chainlink price feed contracts through that Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending
endpoint. token symbols and nothing else.
Blockscout and Etherscan are sent none of your data until you turn on a setting 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". 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 this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node. 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: When it is contacted:
- on every balance refresh (every 10 seconds while the popup is open, every 60 - on every balance refresh (every 10 seconds while the popup is open, every 60
seconds in the background) 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 you review a send to an ENS name
- when a send is prepared and broadcast - when a send is prepared and broadcast
- while a pending transaction is polled for its receipt - 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) What gets sent: your Ethereum addresses (to look up balances and transactions)
and the hash of each transaction you open. 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) **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's 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 >Erasing the saved data deletes every wallet stored in
this browser.</strong this browser.</strong
> >
Nothing on the blockchain changes and no money is moved. If Nothing on the blockchain changes and no money is moved, but
you kept a copy of a wallet's recovery phrase, private key without the exported copy above, or the recovery phrase for
or extended private key, you can add that wallet again. each wallet written down, everything they hold is gone
Without that copy or the exported copy above, everything the forever.
wallet holds is gone forever.
</p> </p>
<p class="text-xs mb-1"> <p class="text-xs mb-1">
To confirm, type To confirm, type
+1 -1
View File
@@ -96,7 +96,7 @@ async function doRefreshAndRender() {
const networkId = state.networkId; const networkId = state.networkId;
const wallets = structuredClone(state.wallets); const wallets = structuredClone(state.wallets);
await Promise.all([ await Promise.all([
refreshPrices(pageClosed.signal), refreshPrices(),
refreshBalances( refreshBalances(
wallets, wallets,
state.rpcUrl, state.rpcUrl,
+1 -1
View File
@@ -69,7 +69,7 @@ function show() {
// null when the scale is unknown: no quantity to show, and none to // null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000. // price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0; amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(tokenId); price = getPrice(symbol);
} }
currentSymbol = symbol; currentSymbol = symbol;
+2 -2
View File
@@ -129,7 +129,7 @@ function renderAmount(txInfo) {
const rawSymbol = isErc20 const rawSymbol = isErc20
? txInfo.tokenSymbol || "?" ? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId); : 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; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
shownAmount(txInfo) + " " + displaySymbol(rawSymbol), shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
@@ -157,7 +157,7 @@ function renderBalance(txInfo) {
$("confirm-balance").textContent = "unknown (" + symbol + ")"; $("confirm-balance").textContent = "unknown (" + symbol + ")";
return; return;
} }
const price = getPrice(txInfo.token, txInfo.chainId); const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null; const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd( $("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol, truncateAmountNeverZero(bal) + " " + symbol,
+2 -2
View File
@@ -439,7 +439,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
bal, bal,
getPrice(t.address), getPrice(t.symbol),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
seen.add(t.address.toLowerCase()); seen.add(t.address.toLowerCase());
@@ -450,7 +450,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
0, 0,
getPrice(t.address), getPrice(t.symbol),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
} }
+21 -97
View File
@@ -1,105 +1,31 @@
// USD prices, read from Chainlink price feeds on Ethereum mainnet, with USD // Price fetching with 5-minute cache, USD formatting, value aggregation.
// formatting and value aggregation. The feeds are in src/shared/tokenList.js.
const { Contract } = require("ethers"); const { getTopTokenPrices } = require("./tokenList");
const { ETH_USD_FEED, PRICE_FEED_BY_ADDRESS } = require("./tokenList");
const { getProvider } = require("./balances");
const { isBelowOneMillionth } = require("./amountDisplay"); const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks"); const { networkByChainId } = require("./networks");
const { log } = require("./log");
const PRICE_CACHE_TTL = 300000; // 5 minutes 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 = {}; const prices = {};
let lastFetchedAt = 0; let lastFetchedAt = 0;
// Reads the price of ether and of each token any address holds or the user async function refreshPrices() {
// tracks, when that token has a feed. Mainnet only. The reads go to the RPC // Testnet tokens have no real market value — skip price fetching
// endpoint as one batched request, at most every 5 minutes. A failed read is // and clear any stale mainnet prices so the UI shows no USD values.
// logged, and that token then has no price. const { currentNetwork } = require("./state");
// if (currentNetwork().isTestnet) {
// `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") {
clearPrices(); clearPrices();
return; return;
} }
const now = Date.now(); const now = Date.now();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return; if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
const feeds = new Map([["ETH", ETH_USD_FEED]]); try {
for (const address of heldOrTrackedTokens(state)) { const fetched = await getTopTokenPrices(25);
const feed = PRICE_FEED_BY_ADDRESS.get(address); Object.assign(prices, fetched);
if (feed) feeds.set(address, feed); 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 // Clear all cached prices and reset the fetch timestamp so the
@@ -111,15 +37,14 @@ function clearPrices() {
lastFetchedAt = 0; lastFetchedAt = 0;
} }
// Return the USD price of the token at the contract address `token`, or of // Return the USD price for a symbol on the network with `chainId`, or on the
// ether when `token` is "ETH". The price is for the network with `chainId`, or // active network when no chain id is given; null on testnet, on a chain id
// for the active network when no chain id is given. Returns null on testnet, // that names no network, or for a symbol with no price.
// on a chain id that names no network, and for a token with no price. function getPrice(symbol, chainId) {
function getPrice(token, chainId) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork(); const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (!network || network.isTestnet) return null; if (!network || network.isTestnet) return null;
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null; return prices[symbol] || null;
} }
function formatUsd(amount) { function formatUsd(amount) {
@@ -137,7 +62,7 @@ function formatUsd(amount) {
// What an address is worth, as { usd, partial }. // 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 // 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 // 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 // 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 // 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. // a holding this can price either, and is left to the same rule.
if (!(tokenBal > 0)) continue; if (!(tokenBal > 0)) continue;
const price = prices[token.address.toLowerCase()]; if (prices[token.symbol]) {
if (price) { usd += tokenBal * prices[token.symbol];
usd += tokenBal * price;
} else { } else {
partial = true; partial = true;
} }
+31 -351
View File
@@ -4,12 +4,12 @@
// Addresses are EIP-55 checksummed. // Addresses are EIP-55 checksummed.
// //
// 511 tokens. // 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 { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
const TOKENS = [ const TOKENS = [
{ {
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 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. // Return the top N tokens from the list.
function getTopTokens(n) { function getTopTokens(n) {
return TOKENS.slice(0, 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 "?". // Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) { function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase(); const lower = (tokenAddress || "").toLowerCase();
@@ -4008,9 +3690,7 @@ module.exports = {
TOKENS, TOKENS,
TOKEN_BY_ADDRESS, TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS, KNOWN_SYMBOLS,
ETH_USD_FEED,
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens, getTopTokens,
getTopTokenPrices,
resolveSymbol, resolveSymbol,
}; };
+98 -73
View File
@@ -1244,6 +1244,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// comes from the transaction's value. A step paid from the router // comes from the transaction's value. A step paid from the router
// takes nothing from the sender. // takes nothing from the sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
let permit = null; let permit = null;
@@ -1336,15 +1340,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}; };
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
// that sets the input side, as { token, amount }, in step order. // that sets the input side, as { token, amount, atMost }, in step
// After the loop each joins the Amount line or gets lines of its own // order. After the loop each joins the Amount line or gets lines of
// (see permitStands). // its own (see permitStands). A take with `atMost` set takes at most
// its amount, and always gets lines of its own.
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes // of `token` from the sender, or at most that when `atMost` is set. A
// nothing. // V2 exact-in of ALREADY_PAID takes nothing.
const addUserPaid = (token, amount) => { const addUserPaid = (token, amount, atMost = false) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount }); if (amount !== ALREADY_PAID) {
userPaid.push({ token, amount, atMost });
}
}; };
// A step may take from the sender what this decoder did not read: a // A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
@@ -1787,6 +1794,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
inputPaidByUser = true; inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} }
addPayee(w.recipient); addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH. // CONTRACT_BALANCE wraps all the router's ETH.
@@ -1815,6 +1825,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished; const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own
// ETH, whichever payer it names. When the router may hold
// ETH from an earlier step, that ETH may pay some of the
// ETH that the user pays this step. The sender then pays
// at most the step's amount.
const routerMayHoldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
if (v4) { if (v4) {
// Each SETTLE paid from the router spends the amount // Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays. // decodeV4Swap() says it pays.
@@ -1905,7 +1923,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (v4) { if (v4) {
for (const p of v4.paidByUser) { for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue; if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount); addUserPaid(
p.currency,
p.amount,
routerMayHoldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
} }
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
@@ -2016,7 +2040,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// being of another token or coming after an amount that is not a // being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the // lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. // 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 // A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the // Amount figure in place of the total, which it does only in the
@@ -2032,14 +2057,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// it may take more. A later permit or batch, or a later step this // 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. // decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as // 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 // they read with no permit, and gets lines of its own below. A step
// on the zero address never stands as the Amount figure, because // that may take from the sender what this decoder did not read leaves
// Permit2 has no allowance of ETH. A step that may take from the // no figure, permit or not (see spentUnread()), and so do a permit
// sender what this decoder did not read leaves no figure, permit or // or batch whose input it cannot read (see permitUnread()) and an
// not (see spentUnread()), and so do a permit or batch whose input it // allowance of Token In to another spender (see toAnotherSpender). An
// cannot read (see permitUnread()) and an allowance of Token In to // EXECUTE_SUB_PLAN step leaves both sides unknown (below).
// another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step
// leaves both sides unknown (below).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -2049,19 +2072,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") && !stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) && sameAddress(permit.spender, toAddress) &&
permit.token !== "0x0000000000000000000000000000000000000000" &&
!permit.allowedToFail && !permit.allowedToFail &&
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as // What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read // { token, amount, atMost, recipient }. A null token is a step this
// (see spentUnread()), or a token it does not know. // 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 = []; const spentFromSender = [];
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, as // The takes from the sender that `total` adds up, each a quantity, in
// { token, amount }. When there is no Amount line, each gets lines of // the shape of `spentFromSender`. When there is no Amount line, each
// its own (see below). The amount of a step that set the input side // gets lines of its own (see below). The amount of a step that set
// and is paid from the router counts in `total` but is not among them. // the input side and is paid from the router counts in `total` but is
// not among them.
const inTotal = []; const inTotal = [];
// Whether the step that set the input side counts as a take from the // Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts // sender, which may get lines of its own. The amount it stated counts
@@ -2076,7 +2101,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) { if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount; total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) { if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount }); inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
@@ -2084,7 +2113,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) { } else if (hasFirstTake) {
// The step that set the input side spent a token other than the // The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own. // permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount }); spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (permitStands) inputToken = permit.token; if (permitStands) inputToken = permit.token;
for (const t of userPaid) { for (const t of userPaid) {
@@ -2092,6 +2125,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
if ( if (
ofTokenIn && ofTokenIn &&
!t.atMost &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" typeof t.amount === "bigint"
) { ) {
@@ -2196,8 +2230,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see // V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be // tests come first — the sentinels are not bigints and cannot be
// compared with one. // compared with one. `atMost` says the amount is a maximum (see
const spentText = (amount, info) => { // userPaid).
const spentText = (amount, info, atMost = false) => {
if (amount === OPEN_DELTA) { if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} }
@@ -2210,7 +2245,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount >= MAX_UINT160) { if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" }; return { raw: "Unlimited", display: "Unlimited" };
} }
if (hasV2OrV3ExactOut) { if (hasV2OrV3ExactOut || atMost) {
// A V2 or V3 exact-out swap spends at most this figure, // A V2 or V3 exact-out swap spends at most this figure,
// whichever steps gave it (an amountInMax, the WRAP_ETH of a // whichever steps gave it (an amountInMax, the WRAP_ETH of a
// swap paid in ETH, a permit, or several steps' total), so it // swap paid in ETH, a permit, or several steps' total), so it
@@ -2237,19 +2272,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A null token in `spentFromSender`, a step this decoder did not read // A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and an allowance to another spender // or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be // 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, // too low. With no Amount line, each take the total adds up gets
// because Permit2 has no allowance of ETH. With no Amount line, each // lines of its own, first, so that every amount this decoder read is
// take the total adds up gets lines of its own, first, so that every // shown.
// amount this decoder read is shown.
if ( if (
present(inputAmount) && present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) && spentFromSender.every((s) => present(s.token)) &&
toAnotherSpender.every( toAnotherSpender.every(
(p) => (p) =>
p.token === "0x0000000000000000000000000000000000000000" || present(p.token) &&
(present(p.token) && present(inputToken) &&
present(inputToken) && !sameAddress(p.token, inputToken),
!sameAddress(p.token, inputToken)),
) )
) { ) {
const amount = spentText(inputAmount, inInfo); const amount = spentText(inputAmount, inInfo);
@@ -2265,20 +2298,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape // What steps take from the sender beyond the Amount line, in the shape
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets // decoder does not know is said so, and a step it did not read gets
// that one line alone. // 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) { for (const s of spentFromSender) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: present(s.amount) value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources)).display ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources)); details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({ if (!present(s.recipient)) {
label: "Moved to", details.push({
value: "The swap step that spends it", 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,
});
}
} }
} }
@@ -2320,36 +2366,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
// Permit2 has no allowance of ETH. An allowance of the zero details.push({
// address reads as one of a token nothing knows: its amount in label: "Permit2 allowance",
// base units, and the zero address as its token. value:
if (p.token === "0x0000000000000000000000000000000000000000") { p.amount >= MAX_UINT160
details.push({ ? "Unlimited"
label: "Permit2 allowance", : amountText(p.amount, tokenInfo(p.token, sources))
value: .display,
p.amount >= MAX_UINT160 });
? "Unlimited" details.push(tokenMovedLine("Allowance token", p.token, sources));
: unknownDecimalsAmount(p.amount),
});
details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
+4 -4
View File
@@ -1,9 +1,9 @@
// The USD total of an address that holds something this build cannot price // The USD total of an address that holds something this build cannot price
// (issue #261). // (issue #261).
// //
// Only a token with a price feed has a price, so an address can hold real // Prices exist for the top 25 tokens only, so an address can hold real assets
// assets with no price attached. Summing what is priced and printing the result // with no price attached. Summing what is priced and printing the result as
// as the total says "$0.00" for an address holding nothing but unpriced tokens — // 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 // 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 // direction that matters. The two are separate facts here, the same way an
// absent holders_count is not a count of zero. // absent holders_count is not a count of zero.
@@ -63,7 +63,7 @@ const PARTLY_PRICED = {
beforeEach(() => { beforeEach(() => {
clearPrices(); clearPrices();
prices.ETH = 2000; prices.ETH = 2000;
prices[USDC] = 1; prices.USDC = 1;
state.wallets = []; state.wallets = [];
state.trackedTokens = []; state.trackedTokens = [];
state.showZeroBalanceTokens = false; state.showZeroBalanceTokens = false;
+3 -4
View File
@@ -226,7 +226,6 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address; const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070"; const TOKEN = "0x7070707070707070707070707070707070707070";
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
function el(id) { function el(id) {
@@ -319,7 +318,7 @@ async function switchReopenAndGoBack(reviewedOn, switchedTo) {
setBackRenderer(makeBackRenderer(state, { confirmTx })); setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({}); confirmTx.init({});
prices.ETH = 2000; prices.ETH = 2000;
prices[USDC] = 1; prices.USDC = 1;
mockNode.asked = []; mockNode.asked = [];
global.fetch.mockClear(); global.fetch.mockClear();
goBack(); 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 () => { 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; mockNode.tokenUnits[reviewedOn] = 50000000n;
await review(reviewedOn, { await review(reviewedOn, {
token: USDC, token: TOKEN,
tokenSymbol: "USDC", tokenSymbol: "USDC",
tokenDecimals: 6, tokenDecimals: 6,
amount: "10", amount: "10",
@@ -653,7 +652,7 @@ describe.each([
"ERC-20 token transfer (USDC)", "ERC-20 token transfer (USDC)",
); );
expect(el("confirm-token-contract").innerHTML).toContain( expect(el("confirm-token-contract").innerHTML).toContain(
`href="${explorerUrl}/address/${USDC}"`, `href="${explorerUrl}/address/${TOKEN}"`,
); );
expect(el("confirm-amount").textContent).toBe( expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"), "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", () => { test("the USD total is shown when prices are known", () => {
prices.ETH = 2000; prices.ETH = 2000;
prices[USDC] = 1; prices.USDC = 1;
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00"); expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.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; const STUB_BLOCK_NUMBER = 21000000;
// A 32-byte zero word, returned for every eth_call but a price feed's // A 32-byte zero word, returned for every eth_call. It is what makes ethers'
// latestRoundData() (see rpcResult() below). It is what makes ethers'
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a // 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 // throw there reaches the console through src/shared/log.js, which fails the
// run on its own. // run on its own.
const ZERO_WORD = "0x" + "0".repeat(64); 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 // One ETH, so the popup's balance lines render something and the wallet does
// not look empty on the approval screen. // not look empty on the approval screen.
const STUB_BALANCE_WEI = 10n ** 18n; const STUB_BALANCE_WEI = 10n ** 18n;
@@ -114,15 +111,6 @@ function rpcResult(req, state) {
return latestBlock(); 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)) { if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
return 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_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03"; const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231"; const SELECTOR_BALANCE_OF = "0x70a08231";
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
// On any contract, a latestRoundData() call is answered with five zero words. // Every eth_call still answers with a zero word except decimals(), symbol(),
// 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(),
// name() and balanceOf() on the stub token. The wallet reads decimals() back // name() and balanceOf() on the stub token. The wallet reads decimals() back
// at signing time to compare with the scale the confirmation screen rendered // 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 // (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; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").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 (to !== STUB_TOKEN.address) return ZERO_WORD;
if (data.startsWith(SELECTOR_DECIMALS)) { if (data.startsWith(SELECTOR_DECIMALS)) {
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.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 // The interception canary's own request. Answered with nothing: what
// is being observed is that it arrived here at all. // is being observed is that it arrived here at all.
if (url.href === WORKER_PROBE_URL) { if (url.href === WORKER_PROBE_URL) {
-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([]);
});
});
-26
View File
@@ -23,9 +23,6 @@
// version field, and showing those users a wipe prompt would be a worse defect // 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. // than the one being fixed. It is migrated in place and keeps working.
const fs = require("fs");
const path = require("path");
const { const {
bootPopup, bootPopup,
cleanupPopup, 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", () => { describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay // 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 // on the wallet list with the last good balances, and only a reopen
+332 -132
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
]; ];
// execute() of these command bytes and inputs, decoded as sent by // execute() of these command bytes and inputs, decoded as sent by
// the user. // the user to `router`.
function decodeSteps(commands, inputs) { function decodeSteps(commands, inputs, router = ROUTER_ADDR) {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
commands.map(() => "uint8"), commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs, inputs,
9999999999n, 9999999999n,
); );
return uniswap.decode(data, ROUTER_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 // 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); expect(linesBetweenTokens(result)).toEqual(lines);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that
// sets the input side and pays its WETH to anyone but the router
// names that recipient on its Moved to line. On next each row read
// The swap step that spends it.
const ONE_ETH = 1000000000000000000n;
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
// The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`.
const wrapLines = (to) => [
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", to],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines("The sender"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V3_600,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a
// SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The two
// rows marked "reads as on next" read the same on next. On next every
// other row added that ETH to the Amount line, or read it as an exact
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user.
const v4EthForUsdc = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, 0n, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. // USDT for USDC.
const v3ExactOut = (amountInMax) => const v3ExactOut = (amountInMax) =>
@@ -3310,135 +3639,6 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT"); 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/359 — the output half, in // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
+3 -3
View File
@@ -103,7 +103,7 @@ describe("balanceLine", () => {
}); });
test("an unknown quantity produces no fiat figure, a zero one does", () => { 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 unknown = balanceLine("NOVEL", null, 3, NOVEL);
const zero = balanceLine("NOVEL", 0, 3, NOVEL); const zero = balanceLine("NOVEL", 0, 3, NOVEL);
// A price times an unknown quantity is not $0.00: that is the same // A price times an unknown quantity is not $0.00: that is the same
@@ -162,7 +162,7 @@ describe("getAddressValue", () => {
// make it incomplete. // make it incomplete.
test("an unknown balance makes the total partial, a zero balance does not", () => { test("an unknown balance makes the total partial, a zero balance does not", () => {
prices.ETH = 2000; prices.ETH = 2000;
prices[NOVEL] = 3; prices.NOVEL = 3;
const unknown = getAddressValue(address(null)); const unknown = getAddressValue(address(null));
const zero = getAddressValue(address("0.0")); const zero = getAddressValue(address("0.0"));
expect(unknown).not.toEqual(zero); expect(unknown).not.toEqual(zero);
@@ -172,7 +172,7 @@ describe("getAddressValue", () => {
test("an unknown balance is not priced as zero of the token", () => { test("an unknown balance is not priced as zero of the token", () => {
prices.ETH = 2000; prices.ETH = 2000;
prices[NOVEL] = 3; prices.NOVEL = 3;
// The same row with a real quantity of 10 is worth $30. Neither that // 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. // figure nor a confident $0.00 may be stated for the unknown one.
expect(getAddressValue(address("10.0"))).toEqual({ expect(getAddressValue(address("10.0"))).toEqual({