14 Commits
Author SHA1 Message Date
clawbot 08e47c39ad fix: a network switch in Settings keeps another page's token on the network it was added on (closes #692)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch in Settings filed under the new network a token or fraud contract that another page stored since this page's last save, because the switch's save writes only the fields this page changed. Before the switch, onChainSwitch() now takes the stored tracked tokens into the page's own list with takeStoredTrackedTokens(), matched by contract address, so a token either page added or removed stays as that page left it. The switch then files that list under the previous network. When the stored record is on another network, no tokens are taken. Fraud contracts are cleared on every switch, as before.

Model: opus-5-5
2026-10-27 18:05:01 +01:00
clawbot 7ba0a5ff75 fix: TransactionDetail drops a Blockscout answer for another transaction (closes #675)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TransactionDetail asks Blockscout for its transaction without waiting for the answer. If the user pressed Back and opened another transaction before Blockscout answered, the second screen showed the first transaction's block, network details, decoded details and raw data. loadFullTxDetails() now drops the answer once TransactionDetail shows another transaction; the same hash on another network counts as another transaction. An answer that lands while Settings is up is still drawn. Every failed request is still logged.

Model: opus-5-5
2026-10-27 15:30:40 +01:00
clawbot cd5951a4f8 fix: Settings never shows "Saved." for a URL the input no longer shows (closes #695)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The RPC and Blockscout "Save" buttons in Settings now store nothing when the input no longer shows the URL that was checked. When another page changes the network, "Use Blockscout" or "Check recipients on Etherscan", this page draws Settings again, and a redraw during the check puts this page's URL back in the input. Before this change, the typed URL was then stored and "Saved." appeared. A save refused because the input changed shows "The URL was not saved because it was changed during the check." A user's own edit during the check refuses the save too.

Model: opus-5-5
2026-10-27 14:34:50 +01:00
clawbot bcdffa56a4 fix: Settings stores the URL on screen when another page stored a different one (closes #688)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The RPC and Blockscout "Save" buttons in Settings now set the URL in the stored record, inside the save. Before this change, the save wrote only fields that differed from what the page last saved. If another page stored a different URL for the same network, a "Save" of the URL this page loaded left the other page's URL stored, and the flash still read "Saved.". The save still refuses a URL when the active network is no longer the one Settings showed.

Model: opus-5-5
2026-10-27 12:36:48 +01:00
clawbot c0a3a02418 fix: a network switch in Settings leaves no balance from the previous network stored (closes #653)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch in Settings could leave the previous network's balance in the stored record. The switch clears the page's balances inside its save, but the save writes only fields the page changed since its last save. When the page had last saved "0" for an address, the cleared "0" was no change, so a balance the background's refresh stored meanwhile stayed under the new network. The switch's save now always writes the cleared balances and the time of the last balance refresh. Every other field merges as before.

Model: opus-5-5
2026-10-27 10:14:06 +01:00
clawbot f550d8c8d2 fix: a zero-minimum balance check never sets the output side (closes #649)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A BALANCE_CHECK_ERC20 of the sender with a zero minimum, or one the router lets fail, no longer sets Token Out or the minimum. Before, it set both whenever the output side held no minimum, so after an UNWRAP_WETH that drops the minimum, a zero check of USDC named USDC as Token Out, a token the swap did not pay. A check with a positive minimum, not allowed to fail, sets the output side as before. Two existing tests that expected the wrong token are corrected, and README states the rule.

Model: opus-5-5
2026-10-27 08:57:59 +01:00
clawbot fc6cffcaba fix: a Permit2 transfer of the zero address never reads as ETH (closes #685)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_TRANSFER_FROM of the zero address read Token moved: ETH, with an ETH figure, though Permit2 moves no ETH. Such a move now names the zero address as its token on Token moved and Token moved into the router, and gives its amount in base units on Moved from the sender and Paid to. The zero-address check from the permit lines is now one function, permit2TokenInfo(), shared by the move lines. A V4 step of ETH that states no amount still gets its own lines beside such a move, so no line about ETH leaving is dropped.

Model: opus-5-5
2026-10-27 07:53:05 +01:00
clawbot 5615b08760 fix: Settings refuses an endpoint URL whose network changed before it was stored (closes #663)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The RPC and Blockscout "Save" buttons in Settings now store a URL only for the network that Settings showed when "Save" was tapped. The URL is set inside the save, under the lock, so the popup cannot follow a network switch saved elsewhere between setting it and writing it. If that network is no longer active on the page or in the stored record, the save is refused and the flash reads "The URL was not saved because the network changed." "Saved." appears only for a stored URL. The RPC check compares the node's chain id with the network shown at the tap.

Model: opus-5-5
2026-10-27 07:27:15 +01:00
clawbot ec6b6b57a3 fix: a V4 SETTLE of ETH paid from the router reads as moved from the sender (closes #682)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
V4Router pays a SETTLE of ETH from the router's own ETH, whichever payer it names, and the router holds the transaction's value. A V4 SETTLE of ETH that names the router as payer now counts as ETH the user pays. With no ETH left in the router by an earlier step, it reads as a SETTLE the user pays. When an earlier step left ETH there, it gets lines of its own reading Up to and the amount. When the amount is not known, as for CONTRACT_BALANCE, its Moved from the sender line reads Unknown (could not be decoded). No line now shows less ETH than such a SETTLE can take.

Model: opus-5-5
2026-10-27 06:49:37 +01:00
clawbot 36b200247a fix: a permit on the zero address never reads as ETH (closes #660)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Permit2 has no allowance of ETH, but the swap decoder read the zero address in a PERMIT2_PERMIT or a batch entry as ETH. Its Allowance token line now names the zero address, as for a token nothing knows. Its amount never stands as the Amount figure, and it never counts as an allowance of Token In, so a route that pays ETH after such a permit shows that ETH. Tests cover the single permit, the batch entry and the 1-wei route.

Model: opus-5-5
2026-10-27 05:12:38 +01:00
clawbot b4e166cdc7 fix: read USD prices from Chainlink price feeds through the RPC endpoint (closes #525)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
CoinDesk now refuses requests without an API key, so mainnet showed no USD prices. Mainnet prices now come from Chainlink's USD price feeds, read with eth_call through the user's RPC endpoint; no other service is asked. The bundled list pairs each of 53 tokens with its feed by contract address, and ETH and WETH use ETH/USD. The popup reads the feeds of ETH and the tokens the user holds or tracks in one batched request, at most every 5 minutes. A reading older than its heartbeat plus 30 minutes, or not positive, is no price. A token with no feed shows no USD value. CoinDesk is gone.

RULES.md still names the CoinDesk price API.

Model: opus-5-5
2026-10-27 04:23:17 +01:00
clawbot 53551c6382 fix: the StateRecovery erase warning names every kind of wallet backup (closes #593)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The StateRecovery erase warning told every user to rely on each wallet's recovery phrase. A wallet imported from a private key or an extended private key has none; its backup is that key. The warning now names all three, in the words the delete-wallet screens use, and README's StateRecovery entry says what a wallet can be added again from. A test reads the warning.

Model: opus-5-5
2026-10-27 03:46:17 +01:00
clawbot 33ab99b7be fix: closing a transaction or signature approval window leaves the request pending (closes #673)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Closing a transaction or signature approval window no longer rejects the request; only Cancel does. The browser can close that window when the user clicks away, for example to copy the password from a password manager. The request stays pending and the site gets no answer. Pressing the toolbar button opens the oldest such request, unless an approved attempt still holds it. A Reject the background refuses keeps the window open with the background's message. The worker points the toolbar back at the wallet when it starts.

While such a request waits, the transaction slot stays held, so another site's eth_sendTransaction gets -32002. An address switch still rejects pending approvals with 4001.

Model: opus-5-5
2026-10-27 00:56:36 +01:00
clawbot b58ecec1f9 fix: AutistMask does not work in Firefox private windows (closes #646)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Firefox gives an extension page in a private window locks of its own, so a popup there could save between the background's read and its write and undo a network switch the background saved. `manifest/firefox.json` now sets `"incognito": "not_allowed"`, so such a page cannot read or write extension storage, and `tests/manifest.test.js` pins the key. README and the user guide say AutistMask does not work in Firefox private windows. Chrome needs no change: in its default incognito mode an extension page shares the background's lock.

A Firefox user on "Never remember history" can no longer run AutistMask at all.

Model: opus-5-5
2026-10-26 23:51:22 +01:00
36 changed files with 3275 additions and 522 deletions
+215 -178
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 two services: Out of the box the extension contacts one service, the configured RPC node. The
wallet uses the node for:
- the configured RPC node, for blockchain interactions - blockchain interactions
- a public CoinDesk API (no API key), for realtime prices. The API receives - USD prices on mainnet, read from Chainlink price feed contracts
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:
@@ -512,7 +512,10 @@ window of its own, and the password is required to be absent from every message
the approval window sends to the background — with the message that would carry the approval window sends to the background — with the message that would carry
it required to be present, so that check cannot pass by observing nothing. That it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). [#157](https://git.eeqj.de/sneak/AutistMask/issues/157). Closing a transaction
or signature approval window is required to leave the dApp's request unanswered.
The suite then opens that approval at the URL the toolbar button holds, and
answers it there.
The limits of that coverage and of the rest of the Chrome suite, none of them The limits of that coverage and of the rest of the Chrome suite, none of them
papered over: papered over:
@@ -521,9 +524,9 @@ papered over:
network with real funds; that remains a human pass before 1.0.0. network with real funds; that remains a human pass before 1.0.0.
- The site-connection prompt is raised through `chrome.action.openPopup()`, and - The site-connection prompt is raised through `chrome.action.openPopup()`, and
headless Chromium's browser-action popup is not a page Playwright can see or headless Chromium's browser-action popup is not a page Playwright can see or
click, so that one prompt is driven at the URL the extension itself puts on click. So that prompt, and an approval reopened after its window closed, are
the action — the same page and the same approval id, but whether a real opened in a tab at the URL the extension puts on the action. Whether a real
toolbar click shows it is not observable here. toolbar click shows that page is not observable here.
- Each site-connection request is made 1.5 seconds after the tab it will be - Each site-connection request is made 1.5 seconds after the tab it will be
driven in is opened (`APPROVAL_TAB_SETTLE_MS` in `tests/e2e/run.js`), because driven in is opened (`APPROVAL_TAB_SETTLE_MS` in `tests/e2e/run.js`), because
opening that tab closes the previous prompt's toolbar popup; a request made opening that tab closes the previous prompt's toolbar popup; a request made
@@ -642,15 +645,24 @@ above, it does not fail on page errors.
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, the StateRecovery screen (the same cases as the Chrome It covers:
suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` - popup load
rejected and then approved, `eth_sendTransaction`, and a closed approval window - the StateRecovery screen, with the same cases as the Chrome suite
rejecting with EIP-1193 4001 — driven through the real content script, - wallet creation through the UI
background page and approval windows. The popup open all along is required to - the Add Token screen
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop - `eth_requestAccounts` from a dApp
it once the switch back is. The `personal_sign` prompt is also required to fit - `personal_sign` from a dApp
its window without scrolling sideways, with the extension's pages at 170% zoom. - `wallet_switchEthereumChain` from a dApp, rejected and then approved
- `eth_sendTransaction` from a dApp
- an approval window closed without an answer, which leaves its request pending
until it is rejected
Each dApp request runs through the real content script and background page, and
opens a real approval window. The popup open all along is required to show the
`[TESTNET]` banner once the switch to Sepolia is approved and to drop it once
the switch back is. The `personal_sign` prompt is also required to fit its
window without scrolling sideways, with the extension's pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and **no npm dependencies at all**: it is built on global `fetch` and
@@ -890,13 +902,14 @@ 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 via CoinDesk API prices.js — ETH/USD and token/USD from Chainlink price feeds
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 (hardcoded) tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
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
@@ -1264,22 +1277,18 @@ see Transaction Decoding), do not always read as a number. They can read:
step pays nothing itself and swaps whatever an earlier step sent to the pair, step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero. such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the - `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a a V2, V3 or V4 swap's minimum of zero. The router passes a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before `BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance` zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
read `0.0000` when the token's scale was known. The router passes a balance neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
check whenever the balance is at least `minBalance`, so a zero `minBalance` lines, as a swap step does. Only a check of the transaction's sender's own
guarantees nothing: it sets `Token Out` and `Min. received` only when the balance sets either line; a check of any other address sets neither (see
output side holds no minimum, not even a zero one, at the point the check is Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail
reached, and otherwise leaves the current token and figure in place. A nonzero without reverting the transaction (see Transaction Decoding) guarantees
`minBalance` sets both lines, as a swap step does. Only a check of the nothing either, whatever its `minBalance`, and is read as one whose
transaction's sender's own balance sets either line; a check of any other `minBalance` is zero. A minimum stated by a V4 swap step the router lets fail
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step is likewise read as zero.
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1359,10 +1368,10 @@ whole swap undecoded.
#### Partial USD totals #### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the Only a token with a Chainlink price feed has a price, so an address can hold
extension has no price for. Worth zero and worth an unknown amount are different assets the extension has no price for. Worth zero and worth an unknown amount
facts and are never collapsed into one number. `getAddressValue()` in are different facts and are never collapsed into one number. `getAddressValue()`
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced in `src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
holdings, and whether an unpriced holding was left out of it — and every screen 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:
@@ -1494,16 +1503,16 @@ wrote last, the record says which build's shape it is in.
Each write reads the record, changes it and writes it back, holding a lock from Each write reads the record, changes it and writes it back, holding a lock from
the read to the write: the browser's own (`navigator.locks`), taken through the read to the write: the browser's own (`navigator.locks`), taken through
`withLock()` in `src/shared/browserApi.js`. Every page of the extension and its `withLock()` in `src/shared/browserApi.js`. Every page of the extension and its
background share it. `manifest/firefox.json` sets `"incognito": "not_allowed"`, background share it, except a page in a Firefox private window, which has locks
so no page runs in a Firefox private window. Firefox would give a page there of its own. `manifest/firefox.json` sets `"incognito": "not_allowed"`, so such a
locks of its own. In Chrome, a page opened from an incognito window still runs page cannot read or write extension storage. In Chrome, a page opened from an
outside incognito and shares the lock. The popup's `saveState()`, the incognito window still runs outside incognito and shares the lock. The popup's
background's `updateState()` and the StateRecovery reset all take it, so no `saveState()`, the background's `updateState()` and the StateRecovery reset all
write lands between another write's read and its write, where it would be lost. take it, so no write lands between another write's read and its write, where it
Chrome has it from version 69, older than any Chrome that runs a Manifest V3 would be lost. Chrome has it from version 69, older than any Chrome that runs a
extension, in pages and in the service worker; Firefox from version 96, older Manifest V3 extension, in pages and in the service worker; Firefox from version
than the 115 `manifest/firefox.json` declares. Where it is missing, a write 96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
fails rather than going ahead without it. write fails rather than going ahead without it.
Version 1 is the shape that shipped before versions existed, so a stored record Version 1 is the shape that shipped before versions existed, so a stored record
with no `schemaVersion` is version 1 rather than a defect: it loads normally and with no `schemaVersion` is version 1 rather than a defect: it loads normally and
@@ -2268,6 +2277,11 @@ view would leave a wallet one click from deletion.
- Endpoint URL input + "Save" button. "Save" asks the URL for `/stats` - Endpoint URL input + "Save" button. "Save" asks the URL for `/stats`
and saves the URL only if that request succeeds. The request carries and saves the URL only if that request succeeds. The request carries
no user data and is made whether or not "Use Blockscout" is ticked. no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so
- Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not
saved
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
@@ -2644,8 +2658,14 @@ view would leave a wallet one click from deletion.
Data Model) → full-sentence error on the error line saying why, nothing Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change) signed (no screen change)
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with - "Reject" after "Confirm", while the transaction is being sent → the
EIP-1193 code 4001 background's message on the error line, no screen change
- Popup window closed without answering → the request stays pending, and the
site gets no answer. The browser can close this window when the user
clicks away from it, for example to copy the password from a password
manager. Until the request is answered, the toolbar button opens this
screen instead of the wallet. When several requests wait with their
windows closed, the toolbar button opens the oldest first.
#### SignApproval (`approve-sign`) #### SignApproval (`approve-sign`)
@@ -2734,8 +2754,11 @@ view would leave a wallet one click from deletion.
- "Sign" (wrong password, or a signing failure) → error line, no screen - "Sign" (wrong password, or a signing failure) → error line, no screen
change change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with - "Reject" after "Sign", while the signature is being checked → the
EIP-1193 code 4001 background's message on the error line, no screen change
- Popup window closed without answering → the request stays pending, as on
TxApproval. Until the request is answered, the toolbar button opens this
screen instead of the wallet.
#### NetworkApproval (`approve-network`) #### NetworkApproval (`approve-network`)
@@ -2799,6 +2822,9 @@ 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
@@ -2830,9 +2856,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 sends the AutistMask is not a fully self-contained offline tool. By default it contacts
user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price only the Ethereum RPC endpoint. USD prices are read from Chainlink price feed
API for prices, sending token symbols and nothing else. contracts through that endpoint.
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:
@@ -2856,21 +2882,18 @@ 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. The does not resolve when its resolver says the name is resolved off-chain. On
default endpoint is a public RPC (configurable by the user to any endpoint mainnet the extension also reads USD prices from Chainlink price feed
they prefer, including a local node). By default the extension talks to contracts (`latestRoundData`). The prices of ETH and of each held or tracked
token with a feed are read as one batched request. That request is sent at
most every 5 minutes while the popup is open. The default endpoint is a public
RPC (configurable by the user to any endpoint they prefer, including a local
node). By default the extension talks to
`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
@@ -3046,14 +3069,13 @@ 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 request made through `debugFetch` in `src/shared/log.js` (the explorer, the RPC
price feed, the RPC calls a site makes, and the endpoint checks in settings) and calls a site makes, and the endpoint checks in settings) and for its response. A
for its response. A request is logged by its HTTP method, the origin of its URL request is logged by its HTTP method, the origin of its URL (scheme, host and
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance port) and, for a JSON-RPC call, the method name; the balance refresh, the token
refresh, the token lookup and a failed endpoint check in settings name the lookup and a failed endpoint check in settings name the endpoint by its origin
endpoint by its origin too. The URL's path and query string, where RPC providers too. The URL's path and query string, where RPC providers put API keys, any user
put API keys, any user name and password in it, and the request body are never name and password in it, and the request body are never logged.
logged.
### Key Decisions ### Key Decisions
@@ -3374,7 +3396,8 @@ Currently supported:
`Balance Check (of another address)`. A check whose input does not decode, `Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router any token, the swap's output or another, a `TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out paid it into ETH, that token stays in the router unless a later step pays out
@@ -3456,119 +3479,133 @@ 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`. A later step that pays
the router spends what the router holds and takes nothing, and so does a V2 an ERC-20 token from the router takes none of that token from the user's
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is wallet. A V2 exact-in whose `amountIn` is `ALREADY_PAID` takes nothing either.
added to the total while both are quantities: a V3 exact-in of 600 USDT and a A V4 `SETTLE` of ETH paid from the router still takes from the user's wallet,
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When since the router holds the transaction's value. When the decoder does not know
the decoder does not know a take's amount, the total is not known and there is the amount of such a `SETTLE`, as for `CONTRACT_BALANCE`, that `SETTLE` counts
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, as a later step's take. When an earlier step left ETH in the router, that ETH
`Unlimited` included, stands as the `Amount` figure in place of the total, may pay part of a `SETTLE` of ETH paid from the router. That `SETTLE` then
which it does only in the plain shape of Uniswap's own routes: it comes before gets lines of its own, with `Up to` before its amount. A later take of
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the `Token In` that only the user's wallet pays is added to the total while both
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both
the router may not let it fail, no step before it takes its token, a paid by the user, read `1000.0000 USDT`. When the decoder does not know a
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, take's amount, the total is not known and there is no `Amount` line. A
and the decoder reads every step after it in full. Permit2 then lets the `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` included,
router take no more of the token than that amount, so the permit covers every stands as the `Amount` figure in place of the total, which it does only in the
take of it. A step before the permit takes under whatever allowance the router plain shape of Uniswap's own routes: it comes before the step that sets the
already had, so it may take more than the permit's amount, which would then input side, it is the only `PERMIT2_PERMIT` in the transaction and there is no
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A included, or a token the decoder does not know, and the decoder reads every
later permit or batch may raise what Permit2 lets the router take, and so may step after it in full. Permit2 then lets the router take no more of the token
a step the decoder does not read in full: a command the decoder does not read than that amount, so the permit covers every take of it. A step before the
(listed at the end of this section), a step whose input it cannot read, in permit takes under whatever allowance the router already had, so it may take
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before more than the permit's amount, which would then read lower than what leaves
the step that sets the input side or after it, leaves `Token In` and the the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router,
`Amount` line as they read with no permit, unless it lets a spender other than then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid
the router take `Token In` (see below): a permit of 5,000 DAI to another from the router, read `Amount 2000.0000 USDT`. A later permit or batch may
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with raise what Permit2 lets the router take, and so may a step the decoder does
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI not read in full: a command the decoder does not read (listed at the end of
to the router. When a permit's amount stands and the step that sets the input this section), a step whose input it cannot read, in whole or in part, and a
side spends a token other than the permit's, or one the decoder does not know, step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the
that step's amount gets lines of its own, unless it states no amount and the input side or after it, leaves `Token In` and the `Amount` line as they read
user pays it none of its input token. Each take the total does not count gives with no permit, unless it lets a spender other than the router take `Token In`
three lines after the `Amount` line (after `Token In` when there is none): one (see below): a permit of 5,000 DAI to another address and then a V3 exact-in
of a token other than `Token In`; one of `Token In` after an amount that is of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on that exact-in and then a permit of 5,000 DAI to the router. A permit on the
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one zero address never stands as the `Amount` figure, because Permit2 has no
whose amount the decoder does not know. When there is no `Amount` line, each allowance of ETH. When a permit's amount stands and the step that sets the
take the total counts gets them too, so that every amount the decoder reads is input side spends a token other than the permit's, or one the decoder does not
shown: the amount the step that sets the input side states, when it is a know, that step's amount gets lines of its own, unless it states no amount and
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is the user pays it none of its input token. Each take the total does not count
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose gives three lines after the `Amount` line (after `Token In` when there is
ETH comes from the transaction's value), and each later take of `Token In` none): one of a token other than `Token In`; one of `Token In` after an amount
added to it. A step that sets the input side and is paid from the router takes that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder wording on the `Amount` line, or no stated amount, as of a V4 exact-out step);
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give an ETH take whose line reads `Up to` (see above); and one whose amount the
lines for both amounts. When the step that sets the input side states no decoder does not know. When there is no `Amount` line, each take the total
amount, the user pays it some of its input token, and there is no `Amount` counts gets them too, so that every amount the decoder reads is shown: the
line, that step gets them too whenever another take's lines give a figure, or amount the step that sets the input side states, when it is a quantity and the
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into user pays that step (a V2 or V3 step whose `payerIsUser` is true, a V4 step
the router, on its `Paid to` line, so that figure cannot read as all that the user pays some of its input token, or a `WRAP_ETH`, whose ETH comes from
leaves. When it states no amount and the user pays it none of its input token, the transaction's value), and each later take of `Token In` added to it. A
as when the router pays it, that step takes none of that token from the user's step that sets the input side gets none when the user pays it none of its
wallet and never gets them, with a permit or without. They come in step order, input token. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins of
except that the step that sets the input side comes first when it has them, 600 USDT and 400 DAI paid by the user, give lines for both amounts. When the
followed, when there is no `Amount` line, by the other takes the total counts: step that sets the input side states no amount, the user pays it some of its
`Moved from the sender`, its amount, worded as the `Amount` line words one, or input token, and there is no `Amount` line, that step gets them too whenever
`Unknown (could not be decoded)` when the decoder does not know it; another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token
`Token moved`, giving the token's address as the `Token In` line does; and gives one, on the lines below or, into the router, on its `Paid to` line, so
`Moved to`, which reads `The swap step that spends it`. A token the decoder that figure cannot read as all that leaves. When it states no amount and the
does not know gives the `Moved from the sender` line alone. A step may also user pays it none of its input token, that step never gets them, with a permit
take from the user's wallet what the decoder did not read: a V4 step whose or without. They come in step order, except that the step that sets the input
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the side comes first when it has them, followed, when there is no `Amount` line,
router still runs; a V2 or V3 exact-in or exact-out step or a by the other takes the total counts: `Moved from the sender`, its amount,
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose worded as the `Amount` line words one, or `Unknown (could not be decoded)`
path is shorter than one token address or has bytes after its last hop; every when the decoder does not know it; `Token moved`, giving the token's address
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands; as the `Token In` line does; and `Moved to`, which reads
and every step it fails on. Each such step gives one `Moved from the sender` `The swap step that spends it`. A token the decoder does not know gives the
line among those, reading `Unknown (could not be decoded)`. That line, or any `Moved from the sender` line alone. A step may also take from the user's
line for a token the decoder does not know, leaves no `Amount` line, whose wallet what the decoder did not read: a V4 step whose input or one of whose
figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2
permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
the router, in step order, gives three lines: `Moved from the sender`, the cannot read, such as a V3 step whose path is shorter than one token address or
amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
token's address as the `Token In` line does; and `Moved to`, which reads `EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such
step gives one `Moved from the sender` line among those, reading
`Unknown (could not be decoded)`. That line, or any line for a token the
decoder does not know, leaves no `Amount` line, whose figure might be too low,
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
`Token moved into the router` line already give the amount, the token and `Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose where it goes, and so its amount is shown once. For a move of the zero
amount does not stand as the `Amount` figure, before the step that sets the address, `Moved from the sender` gives the amount in base units and
input side or after it, gives three lines, in step order, after those for each `Token moved` gives the zero address. For such a move into the router, the
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example `Paid to` line gives the amount in base units and
`Token moved into the router` gives the zero address. Every `PERMIT2_PERMIT`
whose amount does not stand as the `Amount` figure, before the step that sets
the input side or after it, gives three lines, in step order, after those for
each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, `5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose `Allowance spender`, the address it lets take the token. Only a permit whose
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`. A three lines, in step order with those of each `PERMIT2_PERMIT`. For an
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot allowance of the zero address, `Permit2 allowance` gives the amount in base
read, such as one whose token or spender word has a nonzero top byte, may let units and `Allowance token` gives the zero address. A `PERMIT2_PERMIT` or
any spender take any amount of any token: it gives one `Permit2 allowance` `PERMIT2_PERMIT_BATCH` whose input the decoder cannot read, such as one whose
line alone, reading `Unknown (could not be decoded)`. These lines change no token or spender word has a nonzero top byte, may let any spender take any
`Paid to` line: a permit or batch pays nobody. But code that a step runs, such amount of any token: it gives one `Permit2 allowance` line alone, reading
as a V4 pool's hook or a token's own code, can call a spender other than the `Unknown (could not be decoded)`. These lines change no `Paid to` line: a
router, which can then take up to its allowance from the user's wallet before permit or batch pays nobody. But code that a step runs, such as a V4 pool's
the transaction ends. So an allowance to any spender but the router, or a hook or a token's own code, can call a spender other than the router, which
permit or batch the decoder cannot read, makes the minimum read can then take up to its allowance from the user's wallet before the
`Min. paid out`, and when its token is `Token In`, or the decoder does not transaction ends. So an allowance to any spender but the router, or a permit
know its token or `Token In`, it leaves no `Amount` line, whose figure might or batch the decoder cannot read, makes the minimum read `Min. paid out`, and
be too low: a permit of 5,000 USDT to another address and then a V3 exact-in when its token is `Token In`, or the decoder does not know its token or
of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its `Token In`, it leaves no `Amount` line, whose figure might be too low: a
own. A step goes unread when its recipient does not decode as an address, when permit of 5,000 USDT to another address and then a V3 exact-in of 600 USDT
it is a V3 swap step whose path is shorter than one token address or has bytes read with no `Amount` line, and the 600 USDT gets lines of its own. A step
after its last hop, or when it is a command the decoder does not read: every goes unread when its recipient does not decode as an address, when it is a V3
command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, swap step whose path is shorter than one token address or has bytes after its
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, last hop, or when it is a command the decoder does not read: every command
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `PERMIT2_TRANSFER_FROM`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`,
`WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
(`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
them), or a type its release names no command for. `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`PERMIT2_TRANSFER_FROM_BATCH`,
`EXECUTE_SUB_PLAN` and the NFT commands among them), or a type its release
names no command for.
### Non-Goals Forever ### Non-Goals Forever
+19 -16
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 sends your data only to the not have a backend server. Out of the box it contacts only the Ethereum RPC
Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending endpoint. USD prices are read from Chainlink price feed contracts through that
token symbols and nothing else. endpoint.
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,10 +89,16 @@ 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
@@ -131,19 +137,6 @@ 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
@@ -208,6 +201,9 @@ password.
3. Click "Load Temporary Add-on". 3. Click "Load Temporary Add-on".
4. Select `dist/firefox/manifest.json`. 4. Select `dist/firefox/manifest.json`.
AutistMask does not work in Firefox private windows. If Firefox is set to "Never
remember history", every window is private, so AutistMask does not work at all.
## Getting Started ## Getting Started
### Creating a New Wallet ### Creating a New Wallet
@@ -350,6 +346,13 @@ enter your password and click "Confirm" to authorize it. Message and typed-data
signature requests work the same way, with a "Sign" button, and also require signature requests work the same way, with a "Sign" button, and also require
your password. your password.
If a transaction or signature popup closes before you answer, for example when
you click away to copy your password, the request stays open. Closing the popup
does not turn the request down. Click the AutistMask icon in your browser
toolbar to bring the request back. Until you answer, the icon opens the request
instead of your wallet. If several requests are waiting this way, the oldest
comes first.
A connected site can also ask to switch the wallet's network. A popup shows the A connected site can also ask to switch the wallet's network. A popup shows the
site's origin, the current network and the requested network. Nothing changes site's origin, the current network and the requested network. Nothing changes
unless you click "Switch"; clicking "Reject" or closing the popup leaves unless you click "Switch"; clicking "Reject" or closing the popup leaves
+70 -64
View File
@@ -161,9 +161,9 @@ let txApprovalSlot = null;
const APPROVAL_PENDING_CODE = -32002; const APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before // True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared // the transaction is populated, so the other request is being prepared, is on
// or on screen. It does not claim the other one is displayed yet, because for // screen, or waits with its window closed. It does not claim the other one is
// the length of one network round trip it is not. // displayed yet, because for the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE = const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" + "AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" + " already in progress, so this one was not sent. Please finish that" +
@@ -383,14 +383,26 @@ async function proxyRpc(method, params) {
return json.result; return json.result;
} }
// The site-connection approval the toolbar popup is set to open, or null while // The approval the toolbar popup is set to open, or null while it opens the
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup(). // wallet. Set only by updateToolbarPopup() and showInToolbarPopup().
let toolbarPopupApprovalId = null; let toolbarPopupApprovalId = null;
function resetPopupUrl() { // Point the toolbar popup at the oldest pending approval whose window closed,
toolbarPopupApprovalId = null; // or at the wallet when there is none. The user can then answer that approval
// from the toolbar popup. pendingApprovals lists approvals in the order they
// were added, so the first one found is the oldest. An approval held by a
// running attempt is skipped until that attempt ends.
function updateToolbarPopup() {
const closed = Object.values(pendingApprovals).find(
(approval) => approval.windowClosed && !approval.attemptInFlight,
);
toolbarPopupApprovalId = closed ? closed.id : null;
if (actionNs && typeof actionNs.setPopup === "function") { if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" }); actionNs.setPopup({
popup: closed
? "src/popup/index.html?approval=" + closed.id
: "src/popup/index.html",
});
} }
} }
@@ -420,15 +432,16 @@ function settleApproval(id, result, options) {
// approval stops being alive. // approval stops being alive.
releaseTxApprovalSlotFor(id); releaseTxApprovalSlotFor(id);
approval.resolve(result); approval.resolve(result);
resetPopupUrl(); updateToolbarPopup();
return true; return true;
} }
// What a pending approval resolves to when it is given up on rather than // What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign // answered: a site-connection or network-switch window was closed, or the
// approval answers the requesting page in EIP-1193 shape; a site-connection or // window could not be opened at all. An address switch in the wallet gives up
// network-switch approval answers its handler in the shape the popup's // pending approvals too. A tx or sign approval answers the requesting page in
// decision has, as a refusal. // EIP-1193 shape; a site-connection or network-switch approval answers its
// handler in the shape the popup's decision has, as a refusal.
function abandonedResult(approval, code, message) { function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } }; return { error: { code, message } };
@@ -436,8 +449,9 @@ function abandonedResult(approval, code, message) {
return { approved: false, remember: false }; return { approved: false, remember: false };
} }
// A window the user closed without answering is a refusal by the user, which // A site-connection or network-switch window the user closed without answering
// is 4001 and the wording every other rejection path already uses. // is a refusal by the user, which is 4001 and the wording every other rejection
// path already uses.
const APPROVAL_REJECTED_CODE = 4001; const APPROVAL_REJECTED_CODE = 4001;
const APPROVAL_REJECTED_MESSAGE = "User rejected the request."; const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
@@ -465,31 +479,16 @@ const APPROVAL_WINDOW_FAILED_MESSAGE =
function claimApproval(approval) { function claimApproval(approval) {
if (approval.attemptInFlight) return false; if (approval.attemptInFlight) return false;
approval.attemptInFlight = true; approval.attemptInFlight = true;
updateToolbarPopup();
return true; return true;
} }
// Release an approval whose attempt failed in a way the user can retry. // Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it. // Nothing was broadcast, so the next attempt may claim it. If the user closed
// // its window while the attempt ran, the toolbar popup opens it for that retry.
// Unless the window it would be retried in is already gone. The user closed it
// while the attempt was running and settleApproval() declined then, correctly,
// because the attempt still owned the approval; the attempt has now failed, so
// nothing owns it and nothing can reach it. Left standing it would hold the
// requesting page's promise open forever and, with it, the transaction
// approval slot. It is settled here as the rejection the closed window
// already meant.
function releaseApproval(approval) { function releaseApproval(approval) {
approval.attemptInFlight = false; approval.attemptInFlight = false;
if (approval.windowClosed) { updateToolbarPopup();
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
} }
// Open approval in a separate popup window, unless it is no longer pending. // Open approval in a separate popup window, unless it is no longer pending.
@@ -712,9 +711,7 @@ function isExtensionSender(sender) {
// had refused. // had refused.
// //
// TX and sign approvals do not decide here. They stay pending across a // TX and sign approvals do not decide here. They stay pending across a
// disconnect. When their window closes, the windows.onRemoved listener below // disconnect, and across their window closing, until the user answers them.
// rejects them, unless an approved attempt is still running, in which case
// that attempt answers the page.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1]; const id = port.name.split(":")[1];
@@ -742,9 +739,7 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (approval) { if (approval) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
// When its window closes, windows.onRemoved rejects it, // It stays pending until the user answers it.
// unless an approved attempt is still running, in which
// case that attempt answers the page.
return; return;
} }
settleApproval(id, { approved: false, remember: false }); settleApproval(id, { approved: false, remember: false });
@@ -1297,7 +1292,7 @@ async function broadcastAccountsChanged() {
windowsRemove(approval.windowId).catch(() => {}); windowsRemove(approval.windowId).catch(() => {});
} }
} }
resetPopupUrl(); updateToolbarPopup();
// An address switch ends every connection allowed without "Remember". This // An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which // waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives. // are still rejected the moment the switch arrives.
@@ -1489,36 +1484,44 @@ if (runtime.onStartup) {
} }
startBackgroundJobs(); startBackgroundJobs();
// When approval window is closed without a response, treat as rejection. // The toolbar popup keeps its URL when the worker stops. A worker that starts
// "Without a response" is the operative part: the popup stays open across the // holds no approvals, so the toolbar popup is pointed back at the wallet.
// verify and broadcast it is waiting on, so a user closing an apparently-hung updateToolbarPopup();
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real // A transaction or signature approval stays pending when its window closes.
// outcome to the page — and the window is recorded as gone, so that an attempt // The browser can close that window when the user clicks away from it, for
// which then fails retryably settles instead of waiting in a window that no // example to copy the password from a password manager. The approval is marked
// longer exists. // as closed, so the toolbar popup can open it (see updateToolbarPopup()). An
// attempt already running when the window closes goes on and reports its
// outcome to the page.
// //
// A site-connection or network-switch approval whose popup connected its port // This listener rejects a site-connection or network-switch approval whose
// is not decided here. That popup approves and closes in the same breath, and // window closes before its popup connected its port. Once the port is
// this event races the decision on a channel of its own — the same race the // connected, the approval is left to the port. That popup approves and closes
// port exists to end. Its port disconnect says the same thing this event does, // in the same breath, and this event races the decision on a channel of its
// in an order that is defined, so the disconnect is left to say it. The window // own — the same race the port exists to end. Its port disconnect says the
// closing before any port connected is the one case with nothing else to speak // same thing this event does, in an order that is defined, so the disconnect is
// for it, and is rejected here so the dApp is not left waiting on a window that // left to say it. The window closing before any port connected is the one case
// is gone. // with nothing else to speak for it, and is rejected here so the dApp is not
// left waiting on a window that is gone.
if (windowsNs && windowsNs.onRemoved) { if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => { windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
const decidedOnPort = if (approval.type === "tx" || approval.type === "sign") {
approval.type !== "tx" && approval.type !== "sign"; approval.windowClosed = true;
if (decidedOnPort && approval.portConnected) continue; updateToolbarPopup();
const rejection = abandonedResult( continue;
}
if (approval.portConnected) continue;
settleApproval(
id,
abandonedResult(
approval, approval,
APPROVAL_REJECTED_CODE, APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE, APPROVAL_REJECTED_MESSAGE,
),
); );
if (!settleApproval(id, rejection)) approval.windowClosed = true;
} }
}); });
} }
@@ -1663,7 +1666,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
return false; return false;
} }
return true; // The popup waits for this answer before it closes its window.
sendResponse(null);
return false;
} }
// The popup signs; it reports back here when it could not. Keep the // The popup signs; it reports back here when it could not. Keep the
@@ -1863,7 +1868,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
return false; return false;
} }
return true; sendResponse(null);
return false;
} }
// The popup signs; it reports back here when it could not. Keep the // The popup signs; it reports back here when it could not. Keep the
+5 -4
View File
@@ -1994,10 +1994,11 @@
>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, but Nothing on the blockchain changes and no money is moved. If
without the exported copy above, or the recovery phrase for you kept a copy of a wallet's recovery phrase, private key
each wallet written down, everything they hold is gone or extended private key, you can add that wallet again.
forever. Without that copy or the exported copy above, everything the
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
+4 -4
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(), refreshPrices(pageClosed.signal),
refreshBalances( refreshBalances(
wallets, wallets,
state.rpcUrl, state.rpcUrl,
@@ -281,9 +281,9 @@ async function followStoredSettings() {
// zoom it reports, 170% among them, so once the page has its new width (its // zoom it reports, 170% among them, so once the page has its new width (its
// resize event) it is measured again, and the window widened again while the // resize event) it is measured again, and the window widened again while the
// page still falls short, three times at most. A window that cannot grow sends // page still falls short, three times at most. A window that cannot grow sends
// no resize event, and the page stops there. The connection prompt can also // no resize event, and the page stops there. An approval can also show in the
// show in the toolbar popup, whose window is the user's browser window; that // toolbar popup, whose window is the user's browser window; that one is left
// one is left alone. // alone.
async function widenApprovalWindow() { async function widenApprovalWindow() {
const page = document.documentElement; const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return; if (page.scrollWidth <= page.clientWidth) return;
+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(symbol); price = getPrice(tokenId);
} }
currentSymbol = symbol; currentSymbol = symbol;
+22 -7
View File
@@ -55,7 +55,7 @@ const {
} = require("../../shared/approvalVerify"); } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi"); const { runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
@@ -1142,12 +1142,21 @@ function init(_ctx) {
} }
}); });
$("btn-reject-tx").addEventListener("click", () => { // The window closes only once the background has the rejection. A message
notify({ // sent as the window closes can be lost, and a closed window does not
// reject the request. After Confirm, the background refuses a rejection
// while the transaction is being sent. The window then stays open and
// shows the background's message.
$("btn-reject-tx").addEventListener("click", async () => {
const response = await sendMessage({
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
}); }).catch(() => null);
if (response && response.error) {
showError("approve-tx-error", response.error);
return;
}
window.close(); window.close();
}); });
@@ -1270,12 +1279,18 @@ function init(_ctx) {
if (outcome.retryable) setSignButtonBusy(false); if (outcome.retryable) setSignButtonBusy(false);
}); });
$("btn-reject-sign").addEventListener("click", () => { // As for the transaction's Reject above. After Sign, the background
notify({ // refuses a rejection while the signature is being checked.
$("btn-reject-sign").addEventListener("click", async () => {
const response = await sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
}); }).catch(() => null);
if (response && response.error) {
showError("approve-sign-error", response.error);
return;
}
window.close(); window.close();
}); });
} }
+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(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const price = getPrice(txInfo.token, 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(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const price = getPrice(txInfo.token, 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.symbol), getPrice(t.address),
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.symbol), getPrice(t.address),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
} }
+55 -7
View File
@@ -44,6 +44,12 @@ let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
// The network that Settings was last drawn for. A follow of a switch saved
// elsewhere sets `state` to the new network first. Settings is drawn again only
// once the follow's write returns. So both "Save" buttons take this network,
// not the one in `state`.
let shownNetwork = null;
// One row per site origin, however many addresses it appears under, each with // One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove. // an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) { function renderSiteList(containerId, origins, onRemove) {
@@ -199,6 +205,7 @@ function renderWalletListSettings() {
} }
function show() { function show() {
shownNetwork = currentNetwork();
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId; $("settings-network").value = state.networkId;
@@ -306,6 +313,7 @@ function showNewSettings() {
state.checkRecipientsOnEtherscan; state.checkRecipientsOnEtherscan;
if (state.currentView === "settings") { if (state.currentView === "settings") {
markViewRendered("settings"); markViewRendered("settings");
shownNetwork = currentNetwork();
$("settings-network").value = state.networkId; $("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
@@ -319,6 +327,48 @@ function showNewSettings() {
updateDebugBanner(state.currentView); updateDebugBanner(state.currentView);
} }
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
// `networkId`, the one that Settings showed when the user tapped "Save". The
// URL is set in `state` inside the save, holding the lock. So the page cannot
// follow a switch saved elsewhere (followStoredSettings() in
// src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
//
// `input` is the id of the input that the user typed `url` in. When another
// page changes the network, "Use Blockscout" or "Check recipients on
// Etherscan", this page draws Settings again. A redraw during the endpoint
// check puts the URL from `state` back in the input. So if the input no longer
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
let refused = false;
let inputChanged = false;
await saveState((stored) => {
refused =
state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url;
if (refused || inputChanged) return false;
state[field] = url;
stored[field] = url;
});
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
showFlash(
"The URL was not saved because it was changed during the check.",
);
} else {
showFlash("Saved.");
}
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
deleteWallet.init(ctx); deleteWallet.init(ctx);
@@ -330,6 +380,8 @@ function init(_ctx) {
showFlash("Please enter an RPC URL."); showFlash("Please enter an RPC URL.");
return; return;
} }
// The URL is for the network that Settings shows when "Save" is tapped.
const net = shownNetwork;
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
const resp = await debugFetch(url, { const resp = await debugFetch(url, {
@@ -348,7 +400,6 @@ function init(_ctx) {
showFlash("Endpoint returned an error."); showFlash("Endpoint returned an error.");
return; return;
} }
const net = currentNetwork();
if (json.result !== net.chainId) { if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + "."); showFlash("Wrong network: expected " + net.name + ".");
return; return;
@@ -364,9 +415,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
state.rpcUrl = url; await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
await saveState();
showFlash("Saved.");
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
@@ -375,6 +424,7 @@ function init(_ctx) {
showFlash("Please enter a Blockscout API URL."); showFlash("Please enter a Blockscout API URL.");
return; return;
} }
const net = shownNetwork;
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
const resp = await debugFetch(url + "/stats"); const resp = await debugFetch(url + "/stats");
@@ -389,9 +439,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
state.blockscoutUrl = url; await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
await saveState();
showFlash("Saved.");
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
+12
View File
@@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// Whether TransactionDetail still holds the transaction with `txHash` on
// `chainId`. Opening another transaction replaces the one TransactionDetail
// holds. A visit to Settings does not.
function holdsTransaction(txHash, chainId) {
const tx = state.viewData.tx;
return tx?.hash === txHash && tx?.chainId === chainId;
}
// Reads the block, network details and calldata from Blockscout, and decodes // Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not // the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped // asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown. // (src/shared/storedSettings.js). Either way none of them is shown.
//
// Blockscout can answer after the user has opened another transaction. That
// answer is dropped.
async function loadFullTxDetails(txHash, chainId) { async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
@@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!holdsTransaction(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
+33 -2
View File
@@ -47,12 +47,43 @@ function clearPopupCaches() {
// lock, so it is never switched while a follow of a switch saved elsewhere // lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that // waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it. // follow runs first, and this switch is made after it.
//
// The save writes only the fields that differ from what this page last saved.
// When this page last saved a balance of "0", clearing that balance is no
// change. So the switch also resets these fields in the stored record:
//
// - every address's balance and token balances;
// - the time of the last balance refresh;
// - the fraud contracts.
//
// A balance or a fraud contract that the background or another page stored
// since this page's last save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState, takeStoredTrackedTokens } = require("./state");
let net = null; let net = null;
await saveState(() => { await saveState((stored) => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) {
takeStoredTrackedTokens(stored);
}
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
clearPopupCaches(); clearPopupCaches();
}); });
return net; return net;
+97 -21
View File
@@ -1,31 +1,105 @@
// Price fetching with 5-minute cache, USD formatting, value aggregation. // USD prices, read from Chainlink price feeds on Ethereum mainnet, with USD
// formatting and value aggregation. The feeds are in src/shared/tokenList.js.
const { getTopTokenPrices } = require("./tokenList"); const { Contract } = require("ethers");
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;
async function refreshPrices() { // Reads the price of ether and of each token any address holds or the user
// Testnet tokens have no real market value — skip price fetching // tracks, when that token has a feed. Mainnet only. The reads go to the RPC
// and clear any stale mainnet prices so the UI shows no USD values. // endpoint as one batched request, at most every 5 minutes. A failed read is
const { currentNetwork } = require("./state"); // logged, and that token then has no price.
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;
try { const feeds = new Map([["ETH", ETH_USD_FEED]]);
const fetched = await getTopTokenPrices(25); for (const address of heldOrTrackedTokens(state)) {
Object.assign(prices, fetched); const feed = PRICE_FEED_BY_ADDRESS.get(address);
lastFetchedAt = now; if (feed) feeds.set(address, feed);
} 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
@@ -37,14 +111,15 @@ function clearPrices() {
lastFetchedAt = 0; lastFetchedAt = 0;
} }
// Return the USD price for a symbol on the network with `chainId`, or on the // Return the USD price of the token at the contract address `token`, or of
// active network when no chain id is given; null on testnet, on a chain id // ether when `token` is "ETH". The price is for the network with `chainId`, or
// that names no network, or for a symbol with no price. // for the active network when no chain id is given. Returns null on testnet,
function getPrice(symbol, chainId) { // on a chain id that names no network, and for a token with no price.
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[symbol] || null; return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
} }
function formatUsd(amount) { function formatUsd(amount) {
@@ -62,7 +137,7 @@ function formatUsd(amount) {
// What an address is worth, as { usd, partial }. // What an address is worth, as { usd, partial }.
// //
// Prices are fetched for the top 25 tokens only, so an address can hold real // Only a token with a price feed has a price, 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
@@ -99,8 +174,9 @@ 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;
if (prices[token.symbol]) { const price = prices[token.address.toLowerCase()];
usd += tokenBal * prices[token.symbol]; if (price) {
usd += tokenBal * price;
} else { } else {
partial = true; partial = true;
} }
+37 -9
View File
@@ -125,11 +125,12 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// The persisted fields as this page held them when its last loadState() // `baseline` holds the persisted fields as this page held them when its last
// finished, or when its last successful saveState() began, with any network // loadState() finished, or when its last successful saveState() began.
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
// saveState() diffs the live state against this to find only the fields THIS // this page has taken from the stored record since then. saveState() diffs the
// page changed since. // live state against `baseline` to find only the fields THIS page changed
// since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -442,8 +443,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// milder than the other two. // milder than the other two.
// //
// Every other persisted field stays a whole-field diff: // Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no // `fraudContracts`/`viewStack` are arrays of scalars with no per-element
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like // identity to merge by; a save diffs `trackedTokens` whole, and a switch in
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves // `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above, // together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset // but nothing in src/ ever writes an entry into it — it is only ever reset
@@ -591,8 +594,10 @@ function reportSaveFailure(err) {
// //
// `beforeWrite`, when given, is called inside the lock with the stored record // `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change // as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is // `state`, and this save writes that change. It may also change the stored
// written; any other return lets the save go ahead. // record. A field that this page did not change is then written as
// `beforeWrite` left it. When it returns false, nothing is written; any other
// return lets the save go ahead.
function saveState(beforeWrite) { function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite)); const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this // Every failed save is reported, whether or not the caller awaited this
@@ -613,6 +618,28 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(stored)); copyChainSwitchFields(baseline, structuredClone(stored));
} }
// Give this page the tracked tokens of `stored`, a record on this page's
// network that a save of this page has just read (onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`):
//
// - A token that another page tracked or removed since this page's last save
// is tracked or removed in `state` too.
// - A token that this page tracked or removed since then stays as this page
// has it.
//
// `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ.
function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity(
baseline.trackedTokens,
rawState.trackedTokens,
stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
);
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -659,6 +686,7 @@ module.exports = {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
takeStoredTrackedTokens,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+351 -31
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,6 +3597,351 @@ 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);
@@ -3643,33 +3988,6 @@ 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();
@@ -3690,7 +4008,9 @@ 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,
}; };
+129 -57
View File
@@ -479,6 +479,17 @@ function tokenInfo(address, sources) {
}; };
} }
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
// The zero address there reads as a token nothing knows: its amount in base
// units, and the zero address as its token.
function permit2TokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: null, decimals: null, address };
}
return tokenInfo(address, sources);
}
// A swap amount line. With a scale it is the token quantity; with none it is // A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the // the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display` // ERC-20 amount line makes, so the screen has one way of saying it. `display`
@@ -497,16 +508,18 @@ function amountText(raw, info) {
// The line giving a moved token, or the token of a permit, under `label`, in // The line giving a moved token, or the token of a permit, under `label`, in
// the shape of the Token In line, so the screens show it as they show that // the shape of the Token In line, so the screens show it as they show that
// one. ETH has no address. // one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
function tokenMovedLine(label, token, sources) { // ETH has no address.
const info = tokenInfo(token, sources); function tokenMovedLine(label, info) {
if (!present(info.address)) { if (!present(info.address)) {
return { label, value: info.symbol }; return { label, value: info.symbol };
} }
return { return {
label, label,
value: info.symbol ? info.symbol + " (" + token + ")" : token, value: info.symbol
address: token, ? info.symbol + " (" + info.address + ")"
: info.address,
address: info.address,
isToken: true, isToken: true,
}; };
} }
@@ -1240,9 +1253,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputEstablished = false; let inputEstablished = false;
// Whether the user pays the step that set the input side: a V2 or V3 // Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of // swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH // its input currency (see paidByUser in the loop), or a WRAP_ETH,
// comes from the transaction's value. A step paid from the router // whose ETH comes from the transaction's value. A step that pays an
// takes nothing from the sender. // ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
@@ -1338,7 +1352,9 @@ 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 }, in step order.
// After the loop each joins the Amount line or gets lines of its own // After the loop each joins the Amount line or gets lines of its own
// (see permitStands). // (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount.
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. A V2 exact-in of ALREADY_PAID takes
@@ -1613,13 +1629,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
// The router passes this check whenever the owner // The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees // holds at least minBalance, so a zero one guarantees
// nothing and does not replace a minimum an earlier // nothing. A check the router lets fail guarantees
// step stated. A check the router lets fail guarantees // nothing either, so it is read as a zero one. A zero
// nothing either, so it is read as a zero one. Any // check sets neither Token Out nor the minimum. Any
// other minBalance sets the output side as a swap // other minBalance sets the output side as a swap
// does. It pays nobody. // does. The check pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance; const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) { if (minBalance !== 0n) {
setOutput(i, b.token, minBalance, []); setOutput(i, b.token, minBalance, []);
} }
keepsRouterWeth = true; keepsRouterWeth = true;
@@ -1815,6 +1831,41 @@ 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 the SETTLE names. The router holds
// the transaction's value, and any ETH an earlier step
// left it (see heldByRouter). So the user also pays a
// SETTLE of ETH that names the router as payer. The user
// pays all of its amount when the router holds no other
// ETH, and up to its amount otherwise. CONTRACT_BALANCE
// pays all the router holds, an amount the calldata does
// not state.
const routerHeldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
const ethFromRouter = (v4?.settledFromRouter ?? [])
.filter(
(s) =>
s.currency ===
"0x0000000000000000000000000000000000000000",
)
.map((s) => ({
currency: s.currency,
amount:
s.amount === CONTRACT_BALANCE ? null : s.amount,
}));
// What the user pays the step: what decodeV4Swap() says,
// and the ETH of each SETTLE from the router of a known
// amount when the router holds no other ETH.
const paidByUser = v4
? v4.paidByUser.concat(
routerHeldEth
? []
: ethFromRouter.filter((s) =>
present(s.amount),
),
)
: [];
if (v4) { 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.
@@ -1845,10 +1896,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
commandNames[i] += " (could not be decoded)"; commandNames[i] += " (could not be decoded)";
} }
// The step's amount, if it sets the input side: what the // The step's amount, if it sets the input side: what the
// user pays it of its input currency (see // user pays it of its input currency, or, when the user
// decodeV4Swap()), or, when the user pays none of it, // pays none of it, what its swap actions spend of it,
// what its swap actions spend of it, whoever pays that. // whoever pays that.
const paidIn = v4?.paidByUser.find( const paidIn = paidByUser.find(
(p) => p.currency === v4.tokenIn, (p) => p.currency === v4.tokenIn,
); );
const amountIn = paidIn ? paidIn.amount : v4?.amountIn; const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
@@ -1902,11 +1953,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it. // router still runs it.
const setInputHere = inputEstablished && !inputSetBefore; const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined; if (setInputHere) inputPaidByUser = paidIn !== undefined;
if (v4) { for (const p of 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);
} }
// A SETTLE of ETH from the router that `paidByUser` leaves
// out is a take of its own, even on the step that sets
// the input side. Either this decoder does not know its
// amount, or ETH an earlier step left in the router may
// pay part of that amount (see userPaid).
for (const p of ethFromRouter) {
if (!present(p.amount)) {
addUserPaid(p.currency, null);
} else if (routerHeldEth) {
userPaid.push({
token: p.currency,
amount: p.amount,
atMost: true,
});
}
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
spentUnread(); spentUnread();
@@ -2013,10 +2078,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The total is what the steps take of Token In: the amount of the // The total is what the steps take of Token In: the amount of the
// step that set the input side, with each later take of that token // step that set the input side, with each later take of that token
// added to it while both are quantities. A take that cannot be added, // added to it while both are quantities. A take that cannot be added,
// being of another token or coming after an amount that is not a // being of another token, 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), or having
// lines of its own. One whose amount is not known also leaves the // `atMost` set, gets lines of its own. One whose amount is not known
// total unknown, with no figure. // also leaves the total unknown, with no figure.
// //
// 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,12 +2097,14 @@ 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 step // they read with no permit, and gets lines of its own below. A permit
// that may take from the sender what this decoder did not read leaves // on the zero address never stands as the Amount figure, because
// no figure, permit or not (see spentUnread()), and so do a permit // Permit2 has no allowance of ETH. A step that may take from the
// or batch whose input it cannot read (see permitUnread()) and an // sender what this decoder did not read leaves no figure, permit or
// allowance of Token In to another spender (see toAnotherSpender). An // not (see spentUnread()), and so do a permit or batch whose input it
// EXECUTE_SUB_PLAN step leaves both sides unknown (below). // cannot read (see permitUnread()) and an allowance of Token In to
// another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step
// leaves both sides unknown (below).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -2047,6 +2114,7 @@ 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);
@@ -2057,14 +2125,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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, as
// { token, amount }. When there is no Amount line, each gets lines of // { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of a step that set the input side // its own (see below). The amount of the step that set the input side
// and is paid from the router counts in `total` but is not among them. // counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
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
// whoever pays it. Stating none, it counts only when the user pays it // whoever pays it. Stating none, it counts only when the user pays it
// some of its input token: paid from the router, it takes nothing // some of its input token.
// from the sender.
const hasFirstTake = const hasFirstTake =
inputEstablished && (present(inputAmount) || inputPaidByUser); inputEstablished && (present(inputAmount) || inputPaidByUser);
// The take of the step that set the input side, when the total // The take of the step that set the input side, when the total
@@ -2090,7 +2158,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if ( if (
ofTokenIn && ofTokenIn &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" typeof t.amount === "bigint" &&
!t.atMost
) { ) {
total += t.amount; total += t.amount;
inTotal.push(t); inTotal.push(t);
@@ -2193,8 +2262,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` marks an amount that is only the most a
const spentText = (amount, info) => { // take may be (see userPaid).
const spentText = (amount, info, atMost) => {
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 };
} }
@@ -2207,7 +2277,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
@@ -2234,17 +2304,19 @@ 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. With no Amount line, each take the total adds up gets // too low. An allowance of the zero address is never one of Token In,
// lines of its own, first, so that every amount this decoder read is // because Permit2 has no allowance of ETH. With no Amount line, each
// shown. // take the total adds up gets lines of its own, first, so that every
// amount this decoder read is shown.
if ( if (
present(inputAmount) && present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) && spentFromSender.every((s) => present(s.token)) &&
toAnotherSpender.every( toAnotherSpender.every(
(p) => (p) =>
present(p.token) && p.token === "0x0000000000000000000000000000000000000000" ||
(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,11 +2337,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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", tokenInfo(s.token, sources)),
);
details.push({ details.push({
label: "Moved to", label: "Moved to",
value: "The swap step that spends it", value: "The swap step that spends it",
@@ -2280,12 +2355,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own. // where it goes, an address on a line of its own.
for (const m of movedFromSender) { for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources)) value: amountText(m.amount, info).display,
.display,
}); });
details.push(tokenMovedLine("Token moved", m.token, sources)); details.push(tokenMovedLine("Token moved", info));
details.push( details.push(
present(m.to) present(m.to)
? { label: "Moved to", value: m.to } ? { label: "Moved to", value: m.to }
@@ -2315,15 +2390,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
const info = permit2TokenInfo(p.token, sources);
details.push({ details.push({
label: "Permit2 allowance", label: "Permit2 allowance",
value: value:
p.amount >= MAX_UINT160 p.amount >= MAX_UINT160
? "Unlimited" ? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources)) : amountText(p.amount, info).display,
.display,
}); });
details.push(tokenMovedLine("Allowance token", p.token, sources)); details.push(tokenMovedLine("Allowance token", info));
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
@@ -2381,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
} }
for (const m of movedIntoRouter) { for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources); const info = permit2TokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
label: "Paid to", label: "Paid to",
value: value:
@@ -2389,9 +2464,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display + amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)", " from the sender (later steps may not spend all of it)",
}); });
paidTo.push( paidTo.push(tokenMovedLine("Token moved into the router", info));
tokenMovedLine("Token moved into the router", m.token, sources),
);
} }
if (outputHeld) { if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2408,8 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push( paidTo.push(
tokenMovedLine( tokenMovedLine(
"Token moved into the router", "Token moved into the router",
getAddress(token), tokenInfo(getAddress(token), sources),
sources,
), ),
); );
} }
+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).
// //
// Prices exist for the top 25 tokens only, so an address can hold real assets // Only a token with a price feed has a price, so an address can hold real
// with no price attached. Summing what is priced and printing the result as // assets with no price attached. Summing what is priced and printing the result
// the total says "$0.00" for an address holding nothing but unpriced tokens — // as the total says "$0.00" for an address holding nothing but unpriced tokens —
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the // 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;
+134
View File
@@ -12,6 +12,7 @@ const {
cleanupPopup, cleanupPopup,
settle, settle,
unversionedValidProfile, unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
afterEach(cleanupPopup); afterEach(cleanupPopup);
@@ -174,3 +175,136 @@ test("a connection prompt in the toolbar popup leaves the browser window alone",
expect(windows.updates).toEqual([]); expect(windows.updates).toEqual([]);
expect(env.pageErrors).toEqual([]); expect(env.pageErrors).toEqual([]);
}); });
// Each row holds a transaction or signature approval as the background
// describes it, plus the element ids of the screen that shows it. `refusal` is
// the message the background refuses a Reject with.
const TX_AND_SIGN_APPROVALS = [
{
kind: "transaction",
view: "approve-tx",
reject: "btn-reject-tx",
error: "approve-tx-error",
type: "AUTISTMASK_TX_RESPONSE",
refusal: "This transaction is already being sent.",
approval: {
type: "tx",
approvedFrom: ADDRESS,
approvedTx: {
chainId: 1,
to: ADDRESS,
value: "0",
data: "0x",
gasLimit: "21000",
maxFeePerGas: "1000000000",
maxPriorityFeePerGas: "1000000000",
nonce: 0,
},
},
},
{
kind: "signature",
view: "approve-sign",
reject: "btn-reject-sign",
error: "approve-sign-error",
type: "AUTISTMASK_SIGN_RESPONSE",
refusal: "This request is already being signed.",
approval: {
type: "sign",
approvedFrom: ADDRESS,
signParams: {
method: "personal_sign",
message: "0x48656c6c6f",
from: ADDRESS,
},
},
},
];
// A closed transaction or signature window leaves its request pending
// (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
// window only once the background has the rejection. A message sent as the
// window closes can be lost, and the request would stay pending although the
// user rejected it.
test.each(TX_AND_SIGN_APPROVALS)(
"Reject on a $kind approval closes the window only once the background has answered",
async ({ view, reject, type, approval }) => {
let describeApproval = null;
let answerReject = null;
const sent = [];
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
describeApproval = reply;
}
if (msg.type === type) {
sent.push(msg);
answerReject = reply;
}
},
},
});
describeApproval({
...approval,
origin: "https://dapp.example",
isPhishingDomain: false,
});
await settle();
expect(env.visibleViews()).toEqual([view]);
const close = jest.fn();
globalThis.window.close = close;
const clicked = env.click(reject);
await settle();
expect(sent).toEqual([{ type, id: "approval-1", approved: false }]);
expect(close).not.toHaveBeenCalled();
answerReject(null);
await clicked;
expect(close).toHaveBeenCalledTimes(1);
expect(env.pageErrors).toEqual([]);
},
);
// After Confirm or Sign, the background refuses a Reject while the transaction
// is being sent or the signature checked. The request is not rejected then, so
// the window stays open and shows the background's message.
test.each(TX_AND_SIGN_APPROVALS)(
"Reject on a $kind approval that the background refuses shows the background's message and keeps the window open",
async ({ view, reject, error, type, refusal, approval }) => {
let describeApproval = null;
let answerReject = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
describeApproval = reply;
}
if (msg.type === type) answerReject = reply;
},
},
});
describeApproval({
...approval,
origin: "https://dapp.example",
isPhishingDomain: false,
});
await settle();
const close = jest.fn();
globalThis.window.close = close;
const clicked = env.click(reject);
await settle();
answerReject({ error: refusal, retryable: false });
await clicked;
expect(close).not.toHaveBeenCalled();
expect(env.visibleViews()).toEqual([view]);
expect(env.text(error)).toBe(refusal);
expect(env.pageErrors).toEqual([]);
},
);
+294 -47
View File
@@ -241,10 +241,11 @@ function loadBackground(options) {
let connectListener = null; let connectListener = null;
const created = []; const created = [];
const removed = []; const removed = [];
// Every URL the background put on the browser action. A site approval // Every URL the background put on the browser action. When a test gives
// raised through action.openPopup() opens no window at all, so this is // the URL the action holds before the worker starts, that URL comes
// the only place its id appears. // first. A site approval raised through action.openPopup() opens no
const actionPopups = []; // window at all, so this is the only place its id appears.
const actionPopups = opts.toolbarPopup ? [opts.toolbarPopup] : [];
const openPopup = jest.fn(() => Promise.resolve()); const openPopup = jest.fn(() => Promise.resolve());
global.chrome = { global.chrome = {
@@ -774,10 +775,9 @@ describe("one transaction approval at a time", () => {
// The user closes an approval window that looks hung while the attempt // The user closes an approval window that looks hung while the attempt
// behind it is still running, and that attempt then fails in a way that // behind it is still running, and that attempt then fails in a way that
// would normally leave the approval standing for a retry. There is no // leaves the approval standing for a retry. The toolbar popup opens it, so
// window left to retry in, so leaving it standing answers the requesting // the user can still retry it, and answering it frees the slot.
// page never — and holds the slot for the life of the worker with it. test("an approval whose window closed under a failed attempt is retried from the toolbar popup, and frees the next request", async () => {
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred(); const stalled = deferred();
const bg = loadBackground(); const bg = loadBackground();
@@ -808,11 +808,28 @@ describe("one transaction approval at a time", () => {
stalled.resolve(); stalled.resolve();
await settle(); await settle();
expect(first.result()).toEqual({ expect(first.result()).toBeNull();
error: { code: 4001, message: "User rejected the request." }, expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
}); "src/popup/index.html?approval=" + first.id(),
);
bg.setStateReadHook(null); bg.setStateReadHook(null);
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
const second = bg.requestTx(); const second = bg.requestTx();
await settle(); await settle();
expect(second.result()).toBeNull(); expect(second.result()).toBeNull();
@@ -853,8 +870,10 @@ describe("one transaction approval at a time", () => {
const first = bg.requestTx(); const first = bg.requestTx();
await settle(); await settle();
// The user closes the approval window, which rejects it. bg.send(
bg.closeWindow(1); { type: "AUTISTMASK_TX_RESPONSE", id: first.id(), approved: false },
{ url: bg.fromPopup.url },
);
await settle(); await settle();
expect(first.result()).toEqual({ expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." }, error: { code: 4001, message: "User rejected the request." },
@@ -897,7 +916,7 @@ describe("the worker keeps running while an approval is pending", () => {
try { try {
const bg = loadBackground(); const bg = loadBackground();
const calls = global.chrome.runtime.getPlatformInfo; const calls = global.chrome.runtime.getPlatformInfo;
bg.requestTx(); const pending = bg.requestTx();
await settle(); await settle();
expect(startTimer).toHaveBeenCalledTimes(1); expect(startTimer).toHaveBeenCalledTimes(1);
expect(startTimer.mock.calls[0][1]).toBe(20000); expect(startTimer.mock.calls[0][1]).toBe(20000);
@@ -906,11 +925,23 @@ describe("the worker keeps running while an approval is pending", () => {
tick(); tick();
expect(calls).toHaveBeenCalledTimes(1); expect(calls).toHaveBeenCalledTimes(1);
// The user closes the approval window, which rejects it. // A closed window leaves the approval pending, so the calls go on.
bg.closeWindow(1); bg.closeWindow(1);
await settle(); await settle();
tick(); tick();
expect(calls).toHaveBeenCalledTimes(1); expect(calls).toHaveBeenCalledTimes(2);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: false,
},
{ url: bg.fromPopup.url },
);
await settle();
tick();
expect(calls).toHaveBeenCalledTimes(2);
expect(stopTimer).toHaveBeenCalledWith(1); expect(stopTimer).toHaveBeenCalledWith(1);
} finally { } finally {
startTimer.mockRestore(); startTimer.mockRestore();
@@ -1841,6 +1872,237 @@ describe("the interlock releases a failed attempt", () => {
}); });
}); });
// A transaction or signature approval stays pending when its window closes.
// The browser can close that window when the user clicks away from it, for
// example to copy the password from a password manager. The toolbar popup then
// opens the approval, and the user answers it there
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
describe("a closed approval window leaves the request pending", () => {
// The URL the toolbar popup opens now.
function toolbarPopup(bg) {
return bg.actionPopups[bg.actionPopups.length - 1];
}
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + pending.id(),
);
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("Reject in the toolbar popup answers 4001, and the toolbar popup opens the wallet again", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + pending.id(),
);
const reject = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: pending.id(),
approved: false,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
// The popup closes its window only once this answer arrives.
expect(reject.sendResponse).toHaveBeenCalledWith(null);
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("with two closed, the toolbar popup opens the older one first, then the other", async () => {
const bg = loadBackground();
const sign = bg.requestSign();
await settle();
const signId = sign.id();
const tx = bg.requestTx();
await settle();
// The signature request is in window 1 and the transaction in window
// 2. The newer window is closed first.
bg.closeWindow(2);
bg.closeWindow(1);
await settle();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + signId,
);
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id: signId, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + tx.id(),
);
const reject = bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id: tx.id(), approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(tx.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(reject.sendResponse).toHaveBeenCalledWith(null);
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
// The user can close the window while the attempt after Confirm runs. That
// attempt answers the request, so meanwhile the toolbar popup opens the
// wallet. Opened from the toolbar, the approval would look unanswered.
test("when the window closes during an attempt that sends, the toolbar popup keeps opening the wallet", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
bg.closeWindow(1);
await settle();
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("when the window closes during an attempt that fails retryably, the toolbar popup opens the wallet until the attempt ends, then the approval", async () => {
const stalled = deferred();
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.setStateReadHook(async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
});
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
bg.closeWindow(1);
await settle();
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
stalled.resolve();
await settle();
expect(pending.result()).toBeNull();
expect(toolbarPopup(bg)).toBe(
"src/popup/index.html?approval=" + pending.id(),
);
// A retry from the toolbar popup holds the approval again, so the
// toolbar popup opens the wallet while that retry runs.
bg.setStateReadHook(null);
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
test("an address switch still rejects a closed approval", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
bg.send(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
// The worker can stop while an approval waits with its window closed. The
// approval is lost with the worker, but the toolbar popup keeps its URL.
// The next worker points the toolbar popup back at the wallet.
test("a worker that starts points the toolbar popup back at the wallet", () => {
const bg = loadBackground({
toolbarPopup: "src/popup/index.html?approval=lost-approval",
});
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
});
});
// The claim is what makes one approval one broadcast, so it has to hold // The claim is what makes one approval one broadcast, so it has to hold
// against everything else that retires an approval, not just against a second // against everything else that retires an approval, not just against a second
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting // AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
@@ -1961,20 +2223,6 @@ describe("a claimed approval outlives every other retirement path", () => {
}); });
// The refusals above must not cost the rejection its ordinary meaning. // The refusals above must not cost the rejection its ordinary meaning.
test("with no attempt running, closing the window still rejects", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
});
test("with no attempt running, an active-address switch still rejects and closes", async () => { test("with no attempt running, an active-address switch still rejects and closes", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
@@ -2584,15 +2832,12 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// The `isSite &&` half of that skip, which is what keeps it from reaching // That skip must not reach a tx or sign approval. The popup connects its
// a tx or sign approval. The popup connects its port in show() before it // port in show() before it knows the approval's type, and the background
// knows the approval's type, and the background sets portConnected without // sets portConnected without looking at the type either, so a tx approval
// looking at the type either, so a tx approval in the fallback window // carries the flag too. If the window event skipped it, windowClosed would
// carries the flag too. Without the conjunct the window event would skip // never be set, and the toolbar popup would never open the approval.
// it, windowClosed would never be set, releaseApproval() would never settle test("a tx window closed with the port connected is still opened by the toolbar popup", async () => {
// it, and the page would hang — the #271 regression this guard is written
// around.
test("a tx window closed with the port connected still rejects", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
await settle(); await settle();
@@ -2600,9 +2845,10 @@ describe("a site connection decided as the popup closes", () => {
await settle(); await settle();
bg.closeWindow(1); bg.closeWindow(1);
await settle(); await settle();
expect(pending.result()).toEqual({ expect(pending.result()).toBeNull();
error: { code: 4001, message: "User rejected the request." }, expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
}); "src/popup/index.html?approval=" + pending.id(),
);
}); });
}); });
@@ -2994,13 +3240,14 @@ describe("where an approval window opens", () => {
expect(bg.created[1].left).toBeUndefined(); expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined(); expect(bg.created[1].top).toBeUndefined();
// The request waits on the second window rather than failing: // The request waits on the second window rather than failing. Once
// closing that window is refusing the prompt. // that window closes, the toolbar popup opens the request.
expect(sign.result()).toBeNull(); expect(sign.result()).toBeNull();
bg.closeWindow(2); bg.closeWindow(2);
await settle(); await settle();
expect(sign.result()).toEqual({ expect(sign.result()).toBeNull();
error: { code: 4001, message: "User rejected the request." }, expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
}); "src/popup/index.html?approval=" + sign.id(),
);
}); });
}); });
+51
View File
@@ -6,6 +6,10 @@
// approved a site's request, which changes only the stored record. Driven // approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same // through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js. // refresh in the background is in tests/backgroundStateIsolation.test.js.
//
// The last test covers a balance refresh by the background that is saved while
// a switch in Settings waits for the lock
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
const { const {
bootPopup, bootPopup,
@@ -159,3 +163,50 @@ test("a refresh that returns after the background switched the network writes no
expectNothingStored(page); expectNothingStored(page);
}); });
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// token balances, the values that the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
await page.click("btn-settings");
// The background takes the lock first and holds it until the test calls
// `letGo()`. It then saves balances as backgroundRefresh() in
// src/background/index.js does.
let letGo;
const refreshSave = background.updateState(async (fresh) => {
await new Promise((resolve) => {
letGo = resolve;
});
fresh.wallets[0].addresses[0].balance = "7.0";
fresh.wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
fresh.lastBalanceRefresh = Date.now();
});
await page.settle();
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
letGo();
await refreshSave;
await switching;
await page.settle();
expectNothingStored(page);
});
+4 -3
View File
@@ -226,6 +226,7 @@ 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) {
@@ -318,7 +319,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();
@@ -641,7 +642,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: TOKEN, token: USDC,
tokenSymbol: "USDC", tokenSymbol: "USDC",
tokenDecimals: 6, tokenDecimals: 6,
amount: "10", amount: "10",
@@ -652,7 +653,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/${TOKEN}"`, `href="${explorerUrl}/address/${USDC}"`,
); );
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");
}); });
+13 -1
View File
@@ -43,12 +43,15 @@ 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. It is what makes ethers' // A 32-byte zero word, returned for every eth_call but a price feed's
// 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;
@@ -111,6 +114,15 @@ 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];
} }
+41 -8
View File
@@ -1035,11 +1035,14 @@ step(
}, },
); );
// A closed approval window leaves its request pending, and the toolbar button
// opens it (https://git.eeqj.de/sneak/AutistMask/issues/673). WebDriver cannot
// see the toolbar popup, so its page is opened in a tab, at the URL the
// extension put on the button.
step( step(
"closing an approval window rejects the request with 4001", "a closed approval window leaves the request pending until it is rejected",
async (env) => { async (env) => {
const d = env.driver; const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign-closed", "personal_sign", [ await startRequest(d, "sign-closed", "personal_sign", [
@@ -1053,15 +1056,45 @@ step(
// Closed, not rejected: this is the windows.onRemoved path, which can // Closed, not rejected: this is the windows.onRemoved path, which can
// only fire if windows.create() handed back a window id for the // only fire if windows.create() handed back a window id for the
// approval to be matched against — call site 4 in the issue, and the // approval to be matched against. Without that id the toolbar button
// reason suppressing that write-back turns this step red. // never opens the approval, and this step fails.
await d.closeWindow(env.dappWindow); await d.closeWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign-closed", 2000);
await assertUserRejection(d, "sign-closed", "a closed approval window");
assert( assert(
env.server.broadcast.length === before, outcome.settled === "pending",
"a closed approval window still put a transaction on the node", "closing the approval window answered the page: " +
JSON.stringify(outcome),
); );
await d.switchToWindow(env.popupWindow);
const url = await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.browserAction.getPopup({}).then(done, (e) => done(String(e)));`,
);
assert(
url.includes("?approval="),
"the toolbar button does not open the pending approval: " + url,
);
const reopened = await d.newWindow("tab");
await d.switchToWindow(reopened);
await d.navigate(url);
await d.waitVisible("#view-approve-sign");
await d.click("#btn-reject-sign");
await d.switchToWindow(env.dappWindow);
await assertUserRejection(
d,
"sign-closed",
"the signature request rejected from the toolbar button",
);
// A page may close only a window a script opened, so the tab can
// still be open.
const left = await d.findWindow((u) => u.includes("?approval="));
if (left) {
await d.switchToWindow(left);
await d.closeWindow(env.dappWindow);
}
}, },
); );
+9 -6
View File
@@ -251,8 +251,13 @@ 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";
// Every eth_call still answers with a zero word except decimals(), symbol(), // On any contract, a latestRoundData() call is answered with five zero words.
// That is a round with a price of zero, which the wallet counts as no
// price. So no screen shows a USD value beside the amounts the tests compare.
//
// Every other eth_call answers with a zero word except decimals(), symbol(),
// 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
@@ -273,6 +278,9 @@ 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);
@@ -764,11 +772,6 @@ 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) {
+135 -16
View File
@@ -4110,11 +4110,12 @@ async function waitForApprovalWindow(ctx, timeout = 30000) {
// reservation exists. // reservation exists.
// How long the tab creation above is given to take effect before the request // How long the tab creation above is given to take effect before the request
// that raises the next prompt is issued. Dismissing a browser-action popup // that raises the next prompt is issued. Dismissing a browser-action popup
// disconnects its port, and src/background/index.js calls resetPopupUrl() on // disconnects its port, and src/background/index.js calls
// that disconnect; issued too early, the next request's setPopup() is undone // updateToolbarPopup() on that disconnect; issued too early, the next
// by the previous popup's teardown, chrome.action.openPopup() then rejects, // request's setPopup() is undone by the previous popup's teardown,
// and the fallback window it opens dismisses the popup that was just raised — // chrome.action.openPopup() then rejects, and the fallback window it opens
// which settles the fresh approval as a rejection before it can be seen. // dismisses the popup that was just raised — which settles the fresh approval
// as a rejection before it can be seen.
// Every one of those steps was observed. 1.5s is comfortably past it. // Every one of those steps was observed. 1.5s is comfortably past it.
const APPROVAL_TAB_SETTLE_MS = 1500; const APPROVAL_TAB_SETTLE_MS = 1500;
@@ -4140,6 +4141,17 @@ async function reserveApprovalTab(env) {
return env.approvalTab; return env.approvalTab;
} }
// The URL the extension has put on the action: the page the toolbar button
// opens.
function toolbarPopupUrl(env) {
return env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.action.getPopup({}, (u) => resolve(String(u)));
}),
);
}
// The site-connection prompt: the window the extension opened if it managed // The site-connection prompt: the window the extension opened if it managed
// to open one, and the reserved tab at the action's approval URL otherwise. // to open one, and the reserved tab at the action's approval URL otherwise.
async function openSiteApprovalPopup(env, timeout = 30000) { async function openSiteApprovalPopup(env, timeout = 30000) {
@@ -4150,12 +4162,7 @@ async function openSiteApprovalPopup(env, timeout = 30000) {
.find((p) => !p.isClosed() && p.url().includes("?approval=")); .find((p) => !p.isClosed() && p.url().includes("?approval="));
if (existing) return existing; if (existing) return existing;
const url = await env.page.evaluate( const url = await toolbarPopupUrl(env);
() =>
new Promise((resolve) => {
chrome.action.getPopup({}, (u) => resolve(String(u)));
}),
);
if (url.includes("?approval=")) { if (url.includes("?approval=")) {
await env.approvalTab.goto(url); await env.approvalTab.goto(url);
return env.approvalTab; return env.approvalTab;
@@ -5027,6 +5034,116 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
); );
}); });
// A transaction or signature approval stays pending when its window closes.
// The browser can close that window when the user clicks away from it, for
// example to copy the password from a password manager. The toolbar button then
// opens the approval, and the user answers it there
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
//
// This closes the approval window of request `key` and checks that the page
// has no answer. It then opens the approval again from the toolbar button.
// Playwright cannot click that button, so its page is opened in a tab, at the
// URL the extension put on the button.
async function closeAndReopen(env, key, view) {
const approvalWindow = await waitForApprovalWindow(env.ctx);
await visible(approvalWindow, view);
await approvalWindow.close();
const outcome = await settleRequest(env.dapp, key, 2000);
assert(
outcome.settled === "pending",
"closing the approval window answered the page: " +
JSON.stringify(outcome),
);
const url = await toolbarPopupUrl(env);
assert(
url.includes("?approval="),
"the toolbar button does not open the pending approval: " + url,
);
const reopened = await env.ctx.newPage();
await reopened.goto(url);
await visible(reopened, view);
return reopened;
}
// The stub node answers every nonce request with 0, and the wallet refuses a
// nonce it has already broadcast. A run can therefore approve one dApp
// transaction only, so here the signature is approved and the transaction
// rejected.
test("a closed signature approval is signed from the toolbar button (#673)", async (env) => {
await startRequest(env.dapp, "sign-closed", "personal_sign", [
SIGN_HEX,
env.expectedAddress,
]);
const popup = await closeAndReopen(
env,
"sign-closed",
"#view-approve-sign",
);
let outcome;
try {
await popup.fill("#approve-sign-password", PASSWORD);
await clickAndClose(popup, "#btn-approve-sign");
outcome = await settleRequest(env.dapp, "sign-closed");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyMessage(getBytes(SIGN_HEX), outcome.result),
);
assert(
recovered === env.expectedAddress,
"the personal_sign signature recovers to " +
recovered +
", not to the approved address " +
env.expectedAddress,
);
const url = await toolbarPopupUrl(env);
assert(
!url.includes("?approval="),
"the toolbar button still opens the answered approval: " + url,
);
});
test("a closed transaction approval is rejected from the toolbar button (#673)", async (env) => {
const before = env.routeOpts.broadcastTransactions.length;
await startRequest(env.dapp, "tx-closed", "eth_sendTransaction", [
{
from: env.expectedAddress,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const popup = await closeAndReopen(env, "tx-closed", "#view-approve-tx");
try {
await clickAndClose(popup, "#btn-reject-tx");
await assertUserRejection(
env.dapp,
"tx-closed",
"the transaction rejected from the toolbar button",
);
} finally {
await closeApprovalPages(env.ctx);
}
assert(
env.routeOpts.broadcastTransactions.length === before,
"a rejected transaction still reached the RPC",
);
const url = await toolbarPopupUrl(env);
assert(
!url.includes("?approval="),
"the toolbar button still opens the answered approval: " + url,
);
});
// The extension used to centre every approval window on the last focused // The extension used to centre every approval window on the last focused
// window. Centred on another approval window, the new one landed where the // window. Centred on another approval window, the new one landed where the
// browser refused to create it, and the request failed with no window at all // browser refused to create it, and the request failed with no window at all
@@ -5070,8 +5187,8 @@ test("a prompt raised while another approval window has focus opens its own (#29
}); });
await visible(txWindow, "#view-approve-tx"); await visible(txWindow, "#view-approve-tx");
// Closing a window is refusing its prompt, so both answer 4001. await clickAndClose(txWindow, "#btn-reject-tx");
await closeApprovalPages(env.ctx); await clickAndClose(signWindow, "#btn-reject-sign");
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt"); await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt"); await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
}); });
@@ -5678,9 +5795,11 @@ async function main() {
// for the new one. After a test that passed, its windows get five // for the new one. After a test that passed, its windows get five
// seconds to close, and one still open then fails the test: it should // seconds to close, and one still open then fails the test: it should
// have closed itself, and closing it here unreported would hide that. // have closed itself, and closing it here unreported would hide that.
// After a test that already failed, they are closed unreported, so // After a test that already failed, they are closed unreported. That
// that a prompt it left unanswered does not refuse the next one from // rejects a site-connection or network-switch prompt it left
// its site. // unanswered, so the prompt does not refuse the next one from its
// site. A transaction or signature approval stays pending, and can
// fail the tests after it.
if (!failure) { if (!failure) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
let open; let open;
+1 -1
View File
@@ -169,7 +169,7 @@ describe("declared icons", () => {
// save could land between the background's read and its write (README, // save could land between the background's read and its write (README,
// "Stored state and its version"). // "Stored state and its version").
describe("private windows", () => { describe("private windows", () => {
test("firefox never runs AutistMask in a private window", () => { test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed"); expect(readManifest("firefox").incognito).toBe("not_allowed");
}); });
}); });
+265
View File
@@ -253,6 +253,271 @@ test("a switch back in Settings after a follow whose write failed is stored", as
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId); expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
}); });
// The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the
// URL is stored, the background saves a switch to Sepolia. Another write holds
// the lock, so the popup's follow of that switch and the URL's save both wait
// for the lock. Either may take it first. The URL is stored for neither
// network. The flash message says that the network changed.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc", first: "follow" },
{ button: "btn-save-rpc", field: "settings-rpc", first: "save" },
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "follow",
},
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "save",
},
])(
"$button saves nothing when the network changes before the URL is stored ($first first)",
async ({ button, field, first }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks. The RPC check reads
// Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The click handler runs until its save waits for the lock.
const tapSave = async () => {
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
return saving;
};
let saving;
if (first === "save") saving = await tapSave();
await backgroundSwitch(page, "sepolia");
if (first === "follow") saving = await tapSave();
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The popup's follow of the background's switch to Sepolia has set `state` to
// Sepolia. The follow's write has not returned yet, so Settings still shows
// Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum
// Mainnet, so the save stores the URL for neither network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing when tapped while the follow's write waits",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
const { set } = page.storage.local;
const write = set.getMockImplementation();
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
// The background's write lands; the next, the follow's, waits.
set.mockImplementationOnce(write).mockImplementationOnce(
async (items) => {
await writeHeld;
await write(items);
},
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(page.value("settings-network")).toBe("mainnet");
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseWrite();
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for
// the node's answer, the popup follows the background's switch to Sepolia. The
// node answers with Sepolia's chain id. So the check refuses the node for
// Ethereum Mainnet, and nothing is stored.
test("the RPC check compares the answer with the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://sepolia-node.example.invalid";
page.node("settings-rpc").value = url;
const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"Wrong network: expected Ethereum Mainnet.",
);
});
// The user taps the Blockscout "Save" on Ethereum Mainnet. While the check
// waits for Blockscout's answer, the popup follows the background's switch to
// Sepolia. The URL is for Ethereum Mainnet, so nothing is stored.
test("the Blockscout save is for the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://own-explorer.example.invalid/api/v2";
page.node("settings-blockscout").value = url;
const saving = page
.node("btn-save-blockscout")
.listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
expect(page.value("settings-network")).toBe("sepolia");
answer({ ok: true });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
});
// Settings is open when the network changes to Sepolia. The popup follows the
// background's switch, or the user picks Sepolia in the network selector. Each
// "Save" after the switch stores its URL for Sepolia.
test.each([
["a follow", (page) => backgroundSwitch(page, "sepolia")],
[
"the network selector",
async (page) => {
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
},
],
])(
"after a switch to Sepolia by %s, Settings saves each URL for Sepolia",
async (_how, switchToSepolia) => {
const page = await bootOnAddress();
await page.click("btn-settings");
await switchToSepolia(page);
// This answer passes both endpoint checks on Sepolia. The RPC check
// reads Sepolia's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.sepolia.chainId }),
});
const rpcUrl = "https://own-node.example.invalid";
const blockscoutUrl = "https://own-explorer.example.invalid/api/v2";
page.node("settings-rpc").value = rpcUrl;
await page.click("btn-save-rpc");
expect(page.text("flash-msg")).toBe("Saved.");
page.node("settings-blockscout").value = blockscoutUrl;
await page.click("btn-save-blockscout");
expect(page.text("flash-msg")).toBe("Saved.");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.rpcUrl).toBe(rpcUrl);
expect(stored.blockscoutUrl).toBe(blockscoutUrl);
},
);
// The user picks Sepolia in Settings, but the switch's write fails. The page is
// then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save"
// after the failed switch stores nothing for either network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing after a switch in Settings whose write failed",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
page.storage.local.set.mockRejectedValueOnce(
new Error("quota exceeded"),
);
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) {
await expect(fn()).rejects.toThrow("quota exceeded");
}
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
page.node(field).value = url;
await page.click(button);
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// Such as one a newer build wrote, which this build cannot read. Nothing reads // Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as // or writes the stored record for it: the popup's next save finds it, as
// before. // before.
+272
View File
@@ -0,0 +1,272 @@
// 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([]);
});
});
+169
View File
@@ -0,0 +1,169 @@
// Another page stores a different RPC or Blockscout URL for the same network.
// Then the user taps "Save" in Settings on this page, on the URL that this page
// loaded. The URL on screen is stored, and the flash message reads "Saved."
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
// popup over a stored profile on Ethereum Mainnet.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
afterEach(() => {
cleanupPopup();
});
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores the URL on screen after another page stored a different one",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const url = page.value(input);
// This write sends this page no storage change event.
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
[field]: "https://other-page.example.invalid",
});
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
// reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.click(button);
expect(page.storage.read("autistmask")[field]).toBe(url);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". While the check waits for
// the endpoint's answer, another page turns "Use Blockscout" on. This page
// follows that change and draws Settings again, with the stored URL in the
// input. The check then passes. The typed URL is not stored. The flash message
// says that the URL was changed during the check
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again during the check",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await Promise.all(saving);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// As above, but another write holds the lock. So this page's follow of the
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
// input still shows the typed URL. The save then asks for the lock, after the
// follow did. So the follow draws Settings again before the save compares the
// input.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again while the save waits for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.settle();
expect(page.value(input)).toBe(typedUrl);
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
+26
View File
@@ -23,6 +23,9 @@
// 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,
@@ -148,6 +151,29 @@ 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
+161
View File
@@ -5,6 +5,9 @@
// a contract lookup that answers after one. Driven through the real popup over // a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in // a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js. // tests/coldWorkerChainSwitch.test.js.
//
// The tests of what another page stored before a switch in Settings cover a
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
const { const {
bootPopup, bootPopup,
@@ -177,6 +180,164 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
expect(onSepolia).not.toContain("DAI"); expect(onSepolia).not.toContain("DAI");
}); });
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
// last save. This page still holds DAI. The switch to Sepolia does not store
// DAI under Ethereum Mainnet again.
test("a token another page removed before a switch in Settings stays removed", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page removes DAI after this page's last save. That page then
// adds DAI again, typing the contract address in lower case. The two pages'
// entries for DAI match, because contract addresses are compared ignoring
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
// Mainnet.
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page saves a switch to Sepolia after this page's last save.
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
fraudContracts: [NEW_TOKEN_ADDRESS],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.fraudContracts).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
describe("Back after a switch from the Settings gear", () => { describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => { test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet"); const boot = await bootOnAddToken("mainnet");
+202
View File
@@ -0,0 +1,202 @@
// TransactionDetail shows only details that Blockscout gave for the
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
//
// The screen asks Blockscout for its transaction and does not wait for the
// answer. So the user can press Back and open another transaction before
// Blockscout answers for the first. The first answer must then fill nothing on
// the second transaction's screen. An answer that lands while Settings is up
// must still fill TransactionDetail.
//
// Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
// opens it: the current screen is pushed, then transactionDetail.show() draws
// the transaction.
"use strict";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
// network with `chainId`.
function historyEntry(hash, chainId, symbol) {
return {
hash,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId,
};
}
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
// A transaction on Sepolia with the same hash as FIRST.
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
// The URL that asks Blockscout for `entry`, on the network it was sent on.
function requestUrl(entry) {
const blockscout =
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
return blockscout + "/transactions/" + entry.hash;
}
// What Blockscout reports for each transaction, by the URL that asks for it.
const ANSWERS = {
[requestUrl(FIRST)]: {
block_number: 21000001,
nonce: 4441,
fee: { value: "21000000000000" },
raw_input: "0x11111111",
method: "firstMethod",
},
[requestUrl(SECOND)]: {
block_number: 21000002,
nonce: 4442,
fee: { value: "42000000000000" },
raw_input: "0x22222222",
method: "secondMethod",
},
[requestUrl(FIRST_ON_SEPOLIA)]: {
block_number: 21000003,
nonce: 4443,
fee: { value: "63000000000000" },
raw_input: "0x33333333",
method: "sepoliaMethod",
},
};
// The text each answer puts on screen: its block, nonce, fee, method name and
// raw data. No text from one answer appears in another.
const FIRST_SHOWN = [
"21000001",
"4441",
"0.000021 ETH",
"firstMethod",
"0x11111111",
];
const SECOND_SHOWN = [
"21000002",
"4442",
"0.000042 ETH",
"secondMethod",
"0x22222222",
];
const FIRST_ON_SEPOLIA_SHOWN = [
"21000003",
"4443",
"0.000063 SepoliaETH",
"sepoliaMethod",
"0x33333333",
];
// Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) {
return [
"tx-detail-block",
"tx-detail-nonce",
"tx-detail-fee",
"tx-detail-calldata-action",
"tx-detail-rawdata",
]
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
.join("\n");
}
// Opens `entry` on TransactionDetail, as a history screen does.
function open(entry) {
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/transactionDetail").show(entry);
}
// The URLs of every request to Blockscout so far, in order.
function requestedUrls() {
return globalThis.fetch.mock.calls.map(([url]) => url);
}
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
// request at once from ANSWERS, except the request for `held`. Returns the
// page and answer(), which lets the answer for `held` land.
async function bootWithHeldRequest(held) {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
let answer;
const heldAnswer = new Promise((resolve) => {
answer = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
if (url === requestUrl(held)) await heldAnswer;
return { ok: true, status: 200, json: async () => ANSWERS[url] };
});
return { page, answer: () => answer() };
}
// The user opens `first`, whose request to Blockscout is held. Then the user
// presses Back and opens `second`. Then the answer for `first` lands. Returns
// the page.
async function firstAnswerLandsOnSecond(first, second) {
const { page, answer } = await bootWithHeldRequest(first);
open(first);
await page.settle();
await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]);
open(second);
await page.settle();
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
return page;
}
afterEach(() => {
cleanupPopup();
});
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
const drawn = drawnDetails(page);
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
const { page, answer } = await bootWithHeldRequest(FIRST);
open(FIRST);
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
answer();
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["transaction"]);
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
const drawn = drawnDetails(page);
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
});
+341 -7
View File
@@ -3310,6 +3310,334 @@ 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/685 — Permit2 moves no
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
// with an ETH figure.
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
const MOVED_AMOUNT =
"1000000000000000000 base units (decimals unknown)";
test("to another address names the address", () => {
const result = decodeSteps(
[0x00, 0x02],
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
});
test("into the router names the address", () => {
const result = decodeSteps(
[0x02, 0x00],
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
);
expect(detail(result, "Paid to").value).toBe(
"The router, " +
MOVED_AMOUNT +
" from the sender (later steps may not spend all of it)",
);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: ZERO_ADDR,
address: ZERO_ADDR,
isToken: true,
});
});
// The V4 step's SETTLE names the user as payer and states no
// amount. The step's lines say that the ETH amount is not known.
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
const v4EthExactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
590000000n,
ONE_WETH,
),
encodeV4Settle(ZERO_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x02],
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE
// of ETH from the router's own ETH, whichever payer the SETTLE names,
// and the router holds the transaction's value. On next at 36b2002 a
// SETTLE of 1 ETH that names the router as payer gave no ETH line.
describe("a V4 SETTLE of ETH that names the router as payer", () => {
const ONE_ETH = 1000000000000000000n;
// A V4 swap of `amount` of ETH for USDC. The SETTLE names the
// router as payer.
const v4EthFromRouter = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, amount, false),
encodeV4TakeAll(USDC_ADDR, 1n),
],
);
test("after a V3 exact-in of 600 USDT, shows the 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, v4EthFromRouter(ONE_ETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// The UNWRAP_WETH leaves ETH in the router. The SETTLE may spend
// that ETH before the transaction's value.
test("after an UNWRAP_WETH into the router, shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x00, 0x0c, 0x10],
[
encodeV3SwapExactIn(ROUTER_ADDR, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
test("after a WRAP_ETH of 0.5 ETH, adds the SETTLE's 0.5 ETH to the Amount line", () => {
const result = decodeSteps(
[0x0b, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
v4EthFromRouter(ONE_ETH / 2n),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1.0000 ETH"],
]);
});
// The WRAP_ETH sets Token In to ETH, and the UNWRAP_WETH leaves
// ETH in the router. The SETTLE may spend that ETH before the
// transaction's value, so its 1 ETH never joins the Amount line.
test("after a WRAP_ETH and an UNWRAP_WETH into the router, keeps the Amount line and shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x0b, 0x0c, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "0.5000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// A SETTLE of CONTRACT_BALANCE pays all the router holds, an
// amount the calldata does not state.
test("of CONTRACT_BALANCE, on the step that sets the input side, shows an unknown amount moved from the sender", () => {
const result = decodeSteps(
[0x10],
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
ONE_ETH,
1n,
),
settle(ZERO_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
encodeV4TakeAll(USDC_ADDR, 1n),
],
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
});
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
@@ -4052,10 +4380,9 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC"); expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
}); });
// With no minimum stated before it, a zero balance check is what sets the // A zero balance check sets neither Token Out nor the minimum. The route
// output side, and it guarantees nothing. No step pays anyone, so the // pays only the WRAP_ETH's WETH, to the router, where it stays.
// minimum is not said to be received. test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[ [
@@ -4068,9 +4395,16 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT"); expect(detail(result, "Token Out").value).toBe(
expect(detail(result, "Min. paid out").value).toBe( "Unknown (not named in the calldata)",
"None (no minimum guaranteed)", );
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
); );
}); });
+89 -14
View File
@@ -688,6 +688,62 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]); ]);
}); });
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A TAKE the decoder cannot read may pay part of the WETH to the user. // A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => { test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode( const result = decode(
@@ -1661,9 +1717,18 @@ describe("any token left in the router is not received", () => {
), ),
min: "1990.0000 USDC", min: "1990.0000 USDC",
}, },
{ ])("$kind reads as a swap paid to the user", ({ data, min }) => {
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router", const result = decode(data);
data: execute( expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — the SETTLE spends all
// the router's ETH, the transaction's value included, so the sender pays
// an amount the calldata does not state.
test("a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router reads as a swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS), swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS), unwrapWeth(ADDRESS_THIS),
v4Swap( v4Swap(
@@ -1672,12 +1737,23 @@ describe("any token left in the router is not received", () => {
takeAll(USDC), takeAll(USDC),
), ),
), ),
min: "1990.0000 USDC", );
}, expect(labels(result)).toEqual([
])("$kind reads as a swap paid to the user", ({ data, min }) => { "Protocol",
const result = decode(data); "Token In",
expect(labels(result)).toEqual(PAID_TO_USER); "Amount",
expect(detail(result, "Min. received").value).toBe(min); "Moved from the sender",
"Token moved",
"Moved to",
"Token Out",
"Min. received",
"Steps",
"Deadline",
]);
expect(detail(result, "Moved from the sender").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
}); });
}); });
@@ -2800,17 +2876,16 @@ describe("the allow-revert flag", () => {
}, },
); );
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => { test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
const check = balanceCheck(WETH, ONE_WETH); const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe( expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH", "1.0000 WETH",
); );
const withFlag = decode(execute(flagged(check))); const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH); expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
expect(detail(withFlag, "Min. paid out").value).toBe( expect(detail(withFlag, "Min. paid out")).toBeUndefined();
"None (no minimum guaranteed)", expect(detail(withFlag, "Min. received")).toBeUndefined();
);
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
+3 -3
View File
@@ -103,7 +103,7 @@ describe("balanceLine", () => {
}); });
test("an unknown quantity produces no fiat figure, a zero one does", () => { 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({