Author SHA1 Message Date
sneak 4f46515d87 fix: a transaction history that lands after a network switch is dropped (closes #657)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Home, AddressDetail and AddressToken remember which network is active
before they ask Blockscout for the transaction history. Each screen drops
the answer if another network is active on the page or in storage when the
answer lands. AddressDetail and AddressToken also drop an answer that lands
once the screen shows another address. AddressToken also drops an answer
that lands once the screen shows another token.

A dropped answer draws no rows and saves no fraud contracts. A failed
request goes through the same checks. A dropped failure shows no message.
Holding the lock, the save adds new fraud contracts only if the page and
the stored record still name the request's network.

storedNetworkIs() reads the stored network, in src/shared/storedSettings.js.

Model: opus-5-5
2026-10-27 11:57:54 +00: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
clawbot 387f40689e feat: an "Allow weak passwords" setting lets a new wallet have a short password (closes #674)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Settings has a new "Allow weak passwords" checkbox, off in a new profile. While it is off, AddWallet refuses a password shorter than 12 characters, as before. While it is on, AddWallet accepts any password that is not empty. Key derivation is unchanged: one Argon2id derivation at libsodium's interactive setting. README's Key Decisions now states those parameters and why one light derivation is enough: the password's length is what protects the stored secret.

The screens say "password", as README's wording rules require, so the setting is "Allow weak passwords".

Model: opus-5-5
2026-10-26 22:35:02 +01:00
clawbot bf8aa9f5dd fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Out of the box the wallet sent the user's addresses to Blockscout and each recipient's address to Etherscan. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate every request that sends the user's data to those services. Both start off and are offered unticked on Welcome and in Settings. Each request reads its setting from storage before it asks. An answer that lands after the setting went off is dropped. While a setting is off, the screens that rely on it say so. ENS lookups ask only the RPC endpoint, so a name resolved off-chain does not resolve.

RULES.md's External Communication item is now out of date. Its new wording will be proposed on #211 once this lands.

Model: opus-5-5 (implementation, summary); fable-5-1 (prose)
2026-10-26 21:38:29 +01:00
clawbot c316d9ee78 fix: tracked tokens' balances are read through the RPC endpoint (closes #669)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
refreshBalances() now reads the balance of every token tracked on the
active network, at every address, with balanceOf through the same
provider as the ETH balance. The entry takes its holder count and
decimals from Blockscout's entry for the token in the same refresh, or
keeps them from before while Blockscout fails. A failed balanceOf keeps
the token's previous entry, or takes Blockscout's entry when there is
none, and is logged. Blockscout's answer still adds the tokens the user
has not tracked, as before.

The browser test for a hostile symbol now untracks the stub token
first, because a tracked token is listed under the symbol it was added
with.

Model: opus-5-5
2026-10-21 14:16:26 +02:00
clawbot b9bb1569fd fix: a V4 step that spends some of its output currency shows no Min. received (closes #574)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A V4 step's Min. received was the sum of the minimums of the swap
actions that buy its output currency, even when another swap action in
the same step spends some of that currency. The user then receives
less than that figure. decodeV4Swap() now gives no minimum when a swap
action in the step also spends the output currency, as it already does
for other minimums it cannot state. A multi-hop action counts only the
currency it spends and the one it ends with, so a path through an
intermediate currency is not read as spending it.

Model: opus-5-5
2026-10-21 07:34:26 +02:00
clawbot 4be625dc06 fix: an allowance to another spender leaves no Amount figure for its token and no Min. received (closes #658)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Code that a step runs, such as a V4 pool's hook, can call a spender other
than the router, which can then take up to its allowance from the user's
wallet before the transaction ends. The Amount figure and Min. received took
no account of it.

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

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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-20 22:59:51 +02:00
clawbot a3da448f0d fix: a swap's WETH minimum reads as ETH only when all its WETH reaches the UNWRAP_WETH (closes #642)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Between the step that set the output side and the UNWRAP_WETH, only a
step the decoder read that is a PERMIT2_PERMIT, a BALANCE_CHECK_ERC20,
or a SWEEP, TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of a
token other than WETH, and not of ETH to a WETH contract, keeps the
swap's WETH minimum as an ETH minimum; any other step leaves ETH with no
minimum. After an UNWRAP_WETH whose recipient is the router or cannot be
read, any later step but those makes the swap read WETH with no minimum.
Only a step that sets the output side again brings back ETH or a
minimum. A WETH fee paid by PAY_PORTION before the UNWRAP_WETH now shows
no minimum. README Display Consistency states the same rule.

Model: opus-5-5
2026-10-20 21:40:50 +02:00
90 changed files with 9222 additions and 993 deletions
+610 -274
View File
File diff suppressed because it is too large Load Diff
+107 -56
View File
@@ -57,11 +57,12 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No third-party token list APIs.** Token balances come from the same block
explorer you configure for transaction history, and the extension ships its
own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
detection. Any token you want tracked across all your addresses, you add
yourself by contract address.
- **No third-party token list APIs.** The balances of the tokens you track are
read from your RPC endpoint. While "Use Blockscout" is on, the other tokens
come from the same block explorer you configure for transaction history. The
extension ships its own hardcoded list of top ERC-20 contract addresses for
symbol-spoofing detection. Any token you want tracked across all your
addresses, you add yourself by contract address.
- **No backend servers operated by the developer.** Nothing is sent to any
server run by AutistMask. Every network destination is listed below.
@@ -69,8 +70,13 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. It communicates with five external destinations:
three you configure yourself, and two fixed ones used for scam detection.
not have a backend server. Out of the box it contacts only the Ethereum RPC
endpoint. USD prices are read from Chainlink price feed contracts through that
endpoint.
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".
Both are off in a new install. Welcome and Settings both offer them.
### External Services
@@ -83,11 +89,20 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node.
When it is contacted: on every balance refresh (every 10 seconds while the popup
is open, every 60 seconds in the background), when you type an ENS name into the
Send screen, when a send is prepared and broadcast, while a pending transaction
is polled for its receipt, and for the reverse ENS lookups used to label
addresses (cached for 12 hours).
On mainnet, AutistMask also reads USD prices through this endpoint. The prices
come from Chainlink's price feed contracts. A token with no Chainlink feed shows
no USD value.
When it is contacted:
- on every balance refresh (every 10 seconds while the popup is open, every 60
seconds in the background)
- on mainnet, to read USD prices, at most every 5 minutes while the popup is
open
- when you review a send to an ENS name
- when a send is prepared and broadcast
- while a pending transaction is polled for its receipt
- for the reverse ENS lookups used to label addresses (cached for 12 hours)
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
@@ -96,42 +111,49 @@ addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
balances (including the holder counts used for spam filtering) and your recent
transactions and token transfers. You can change this in Settings to a
self-hosted Blockscout instance.
Blockscout is an open-source blockchain explorer. AutistMask sends it your data
only while "Use Blockscout" is on. AutistMask then asks Blockscout for:
When it is contacted: on every balance refresh, and whenever a screen showing
transaction history is opened.
- your recent transactions and token transfers
- the balances of the tokens you have not tracked, with the holder counts used
for spam filtering
- the block, fees and calldata of a transaction you open
What gets sent: your Ethereum addresses (to look up balances and transactions).
You can change the endpoint in Settings to a self-hosted Blockscout instance.
While the setting is off, each screen that would show Blockscout's data says
instead that the setting is off. Only the tokens you track are listed. When you
turn the setting off, every other token leaves the lists at once.
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
When it is contacted, while "Use Blockscout" is on:
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.
- on every balance refresh
- whenever a screen showing transaction history is opened
- when you open a transaction
When it is contacted: while the popup is open, at most once every 5 minutes.
Pressing "Save" beside the Blockscout URL in Settings checks the URL by asking
it for `/stats`. That happens whether or not the setting is on. The request
sends none of your data.
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.
What gets sent: your Ethereum addresses (to look up balances and transactions)
and the hash of each transaction you open.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
warning, and shows a red warning if it finds one. This is a plain page fetch
with no API key, made by your browser. It is best-effort: if it fails, it is
silently ignored. This endpoint is not user-configurable.
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's
public Etherscan address page when you review a send. AutistMask looks for a
"Fake_Phishing"/"Phish/Hack" label or a scam warning, and shows a red warning if
it finds one. This is a plain page fetch with no API key, made by your browser.
It is best-effort: if it fails, it is silently ignored. This endpoint is not
user-configurable.
When it is contacted: each time you reach the send confirmation screen.
The setting is off in a new install. While it is off, the confirmation screen
says that no Etherscan check was made.
What gets sent: the recipient address you are about to send to, and your IP
address. Your own addresses are not sent.
When it is contacted: only while "Check recipients on Etherscan" is on, each
time you reach the send confirmation screen.
What gets sent: the recipient's address and nothing else. As with any request,
Etherscan sees your IP address.
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them.
@@ -179,12 +201,18 @@ password.
3. Click "Load Temporary Add-on".
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
### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
On first use, the screen with "Add wallet" also offers "Use Blockscout" and
"Check recipients on Etherscan", both off. External Services above says what
each one does. You can change both later in Settings.
3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these
@@ -220,10 +248,14 @@ always produce the same sequence of addresses.
### Adding ERC-20 Tokens
Tokens you hold show up automatically only if they are in the extension's
bundled list of well-known tokens or have at least 1,000 holders; everything
else is treated as spam and hidden. To track a token explicitly (which also
shows it at zero balance), add it by contract address:
The wallet looks for tokens you hold only while "Use Blockscout" is on. Even
then, a token shows up only if it is in the extension's bundled list of
well-known tokens or has at least 1,000 holders. Everything else is treated as
spam and hidden.
The wallet shows every token you track, whether or not "Use Blockscout" is on.
By default a tracked token is shown at zero balance too (see Display in
Settings). To track a token, add it by contract address:
1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token".
@@ -237,9 +269,12 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending
1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
2. Select what to send: ETH, or a token you track that this address holds. While
"Use Blockscout" is on, Send also offers other tokens with a balance on this
address that survive the spam filters.
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). AutistMask does
not look up a name resolved off-chain. For such a name, enter the address
instead.
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
decimal places, or your ETH balance minus the amount reserved for the network
fee.
@@ -256,9 +291,13 @@ The confirmation screen shows:
estimate, and below it the larger amount reserved until it confirms. The
reserve is what the network requires up front and what the balance check gates
on; the refund of the difference is why the two differ
- **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan
- **Warnings** if the recipient is:
- a contract
- a burn address
- one of your own addresses
- on the bundled scam-address list
- while "Check recipients on Etherscan" is on, labelled as a phisher on
Etherscan
After reviewing, enter your password and click "Sign & Send". The transaction
will be broadcast to the network and you will see a waiting screen with a timer.
@@ -269,8 +308,9 @@ screen with the transaction hash and an Etherscan link.
You can also send from a token-specific view: on the address detail screen,
click any token balance row. This opens a focused view showing only that token's
transactions. Clicking "Send" from here pre-selects and locks the token so you
cannot accidentally switch to a different one.
transactions. While "Use Blockscout" is off, the view says instead that no
transaction history is shown. Clicking "Send" from here pre-selects and locks
the token so you cannot accidentally switch to a different one.
## Receiving
@@ -306,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
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
site's origin, the current network and the requested network. Nothing changes
unless you click "Switch"; clicking "Reject" or closing the popup leaves
@@ -344,11 +391,12 @@ balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. When the explorer reports no holder
count for a token, or reports something other than a whole number in plain
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
token from your transaction history or the send token list, and it does not get
a token into your balances either: such a token is listed only if it is on the
bundled known-token list or you added it yourself. The screen you reach by
clicking a token balance shows a "Holders:" line only when the count is known.
digits (such as "1,000"), the count is unknown. While "Use Blockscout" is off,
no holder count is known. An unknown count does not hide a token from your
transaction history or the send token list, and it does not get a token into
your balances either: such a token is listed only if it is on the bundled
known-token list or you added it yourself. The screen you reach by clicking a
token balance shows a "Holders:" line only when the count is known.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that
@@ -380,6 +428,8 @@ see everything unfiltered.
Click the gear icon on the home screen to access settings:
- **Wallets**: Your wallets, and "+ Add wallet".
- **Passwords**: Tick "Allow weak passwords" to choose a password shorter than
12 characters when you add a wallet.
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
@@ -389,8 +439,9 @@ Click the gear icon on the home screen to access settings:
that network's defaults if you have not used it before.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
transaction history. You can use a self-hosted instance.
- **Blockscout API**: the "Use Blockscout" checkbox, off until you tick it, and
the URL of the Blockscout instance. You can use a self-hosted instance.
- **Etherscan**: "Check recipients on Etherscan", off until you tick it.
- **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
+1
View File
@@ -4,6 +4,7 @@
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"incognito": "not_allowed",
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": {
"16": "icons/icon16.png",
+93 -68
View File
@@ -17,7 +17,11 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state");
const { StateUnusableError } = require("../shared/stateSchema");
const { refreshBalances, getProvider } = require("../shared/balances");
const {
refreshBalances,
dropUntrackedTokens,
getProvider,
} = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
verifySignedTx,
@@ -157,9 +161,9 @@ let txApprovalSlot = null;
const APPROVAL_PENDING_CODE = -32002;
// 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
// or on screen. It does not claim the other one is displayed yet, because for
// the length of one network round trip it is not.
// the transaction is populated, so the other request is being prepared, is on
// screen, or waits with its window closed. It does not claim the other one is
// displayed yet, because for the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" +
@@ -379,14 +383,26 @@ async function proxyRpc(method, params) {
return json.result;
}
// The site-connection approval the toolbar popup is set to open, or null while
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
// The approval the toolbar popup is set to open, or null while it opens the
// wallet. Set only by updateToolbarPopup() and showInToolbarPopup().
let toolbarPopupApprovalId = null;
function resetPopupUrl() {
toolbarPopupApprovalId = null;
// Point the toolbar popup at the oldest pending approval whose window closed,
// 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") {
actionNs.setPopup({ popup: "src/popup/index.html" });
actionNs.setPopup({
popup: closed
? "src/popup/index.html?approval=" + closed.id
: "src/popup/index.html",
});
}
}
@@ -416,15 +432,16 @@ function settleApproval(id, result, options) {
// approval stops being alive.
releaseTxApprovalSlotFor(id);
approval.resolve(result);
resetPopupUrl();
updateToolbarPopup();
return true;
}
// 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
// approval answers the requesting page in EIP-1193 shape; a site-connection or
// network-switch approval answers its handler in the shape the popup's
// decision has, as a refusal.
// answered: a site-connection or network-switch window was closed, or the
// window could not be opened at all. An address switch in the wallet gives up
// pending approvals too. A tx or sign approval answers the requesting page in
// 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) {
if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } };
@@ -432,8 +449,9 @@ function abandonedResult(approval, code, message) {
return { approved: false, remember: false };
}
// A window the user closed without answering is a refusal by the user, which
// is 4001 and the wording every other rejection path already uses.
// A site-connection or network-switch window the user closed without answering
// 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_MESSAGE = "User rejected the request.";
@@ -461,31 +479,16 @@ const APPROVAL_WINDOW_FAILED_MESSAGE =
function claimApproval(approval) {
if (approval.attemptInFlight) return false;
approval.attemptInFlight = true;
updateToolbarPopup();
return true;
}
// Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it.
//
// 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.
// 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.
function releaseApproval(approval) {
approval.attemptInFlight = false;
if (approval.windowClosed) {
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
updateToolbarPopup();
}
// Open approval in a separate popup window, unless it is no longer pending.
@@ -708,9 +711,7 @@ function isExtensionSender(sender) {
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect. When their window closes, the windows.onRemoved listener below
// rejects them, unless an approved attempt is still running, in which case
// that attempt answers the page.
// disconnect, and across their window closing, until the user answers them.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
@@ -738,9 +739,7 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
// When its window closes, windows.onRemoved rejects it,
// unless an approved attempt is still running, in which
// case that attempt answers the page.
// It stays pending until the user answers it.
return;
}
settleApproval(id, { approved: false, remember: false });
@@ -1293,7 +1292,7 @@ async function broadcastAccountsChanged() {
windowsRemove(approval.windowId).catch(() => {});
}
}
resetPopupUrl();
updateToolbarPopup();
// An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives.
@@ -1379,6 +1378,17 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip.
//
// Nothing is applied when the network stored NOW is not the one the refresh
// ran on. A network switch during the round trip cleared every address's
// balances (applyChainSwitchFields()), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network.
//
// Blockscout may have answered just before the user turned "Use Blockscout"
// off. So the save reads the stored setting again, and drops every token the
// user has not tracked if the setting is off.
async function backgroundRefresh() {
const s = await getState();
const now = Date.now();
@@ -1402,6 +1412,7 @@ async function backgroundRefresh() {
}
await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
@@ -1415,6 +1426,9 @@ async function backgroundRefresh() {
}
}
}
if (!fresh.useBlockscout) {
dropUntrackedTokens(fresh.wallets, fresh.trackedTokens);
}
fresh.lastBalanceRefresh = now;
});
}
@@ -1470,36 +1484,44 @@ if (runtime.onStartup) {
}
startBackgroundJobs();
// When approval window is closed without a response, treat as rejection.
// "Without a response" is the operative part: the popup stays open across the
// verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page — and the window is recorded as gone, so that an attempt
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
// The toolbar popup keeps its URL when the worker stops. A worker that starts
// holds no approvals, so the toolbar popup is pointed back at the wallet.
updateToolbarPopup();
// 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 approval is marked
// 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
// is not decided here. That popup approves and closes in the same breath, and
// this event races the decision on a channel of its own — the same race the
// port exists to end. Its port disconnect says the same thing this event does,
// in an order that is defined, so the disconnect is left to say it. The window
// closing before any port connected is the one case with nothing else to speak
// for it, and is rejected here so the dApp is not left waiting on a window that
// is gone.
// This listener rejects a site-connection or network-switch approval whose
// window closes before its popup connected its port. Once the port is
// connected, the approval is left to the port. That popup approves and closes
// in the same breath, and this event races the decision on a channel of its
// own — the same race the port exists to end. Its port disconnect says the
// same thing this event does, in an order that is defined, so the disconnect is
// left to say it. The window closing before any port connected is the one case
// 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) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue;
const decidedOnPort =
approval.type !== "tx" && approval.type !== "sign";
if (decidedOnPort && approval.portConnected) continue;
const rejection = abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
if (approval.type === "tx" || approval.type === "sign") {
approval.windowClosed = true;
updateToolbarPopup();
continue;
}
if (approval.portConnected) continue;
settleApproval(
id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
if (!settleApproval(id, rejection)) approval.windowClosed = true;
}
});
}
@@ -1644,7 +1666,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
});
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
@@ -1844,7 +1868,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
});
return false;
}
return true;
sendResponse(null);
return false;
}
// The popup signs; it reports back here when it could not. Keep the
+5 -7
View File
@@ -18,11 +18,10 @@
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored
// record takes, so no other write lands in between, by
// the background or by any page except a page in a
// Firefox private window (README, "Stored state and its
// version"). The read is one storage round trip ahead of
// the write rather than a page lifetime ahead of it
// (which is what made the popup's saveState() need a
// the background or by any page (README, "Stored state
// and its version"). The read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
@@ -73,8 +72,7 @@ async function updateStateOnce(mutate) {
// src/shared/browserApi.js) is held from the read to the write: two
// background turns, or a turn and a page's save, never interleave
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
// while it is held, except a page in a Firefox private window, which Firefox
// gives locks of its own (README, "Stored state and its version").
// while it is held (README, "Stored state and its version").
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds,
+139 -12
View File
@@ -43,6 +43,38 @@
<!-- ============ WELCOME / FIRST USE ============ -->
<div id="view-welcome" class="view hidden">
<p class="mb-3">Welcome! To get started, add a wallet.</p>
<div class="bg-well p-3 mx-1 mb-3">
<p class="text-xs mb-2">
This wallet sends your data only to the Ethereum RPC
endpoint, unless you turn on one of these. You can
change them later in Settings.
</p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="welcome-use-blockscout" />
Use Blockscout
</label>
<p class="text-xs text-muted mb-2">
Shows transaction history, transaction details and the
tokens you have not tracked. Sends your addresses, and
the hash of each transaction you open, to the Blockscout
API.
</p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="welcome-check-recipients-on-etherscan"
/>
Check recipients on Etherscan
</label>
<p class="text-xs text-muted">
Sends each recipient's address to Etherscan to check it
for a phishing or scam label.
</p>
</div>
<button
id="btn-welcome-add"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -272,6 +304,10 @@
<!-- wallet list -->
<div id="wallet-list"></div>
<p id="home-tokens-note" class="text-xs text-muted py-1 hidden">
Only tracked tokens are listed, because "Use Blockscout" is
off in Settings.
</p>
<!-- recent transactions across all addresses -->
<div>
@@ -336,6 +372,13 @@
<div id="address-balances" class="min-h-[1.25rem]">
&nbsp;
</div>
<p
id="address-tokens-note"
class="text-xs text-muted hidden"
>
Only tracked tokens are listed, because "Use Blockscout"
is off in Settings.
</p>
</div>
<!-- actions -->
@@ -526,12 +569,23 @@
</div>
<div class="mb-2">
<label class="block mb-1">What to send</label>
<select
id="send-token"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="ETH">ETH</option>
</select>
<!-- The token dropdown and the line under it. Both are
hidden when AddressToken opens Send for its own token. -->
<div id="send-token-dropdown">
<select
id="send-token"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="ETH">ETH</option>
</select>
<p
id="send-tokens-note"
class="text-xs text-muted hidden"
>
Only tracked tokens are listed, because "Use
Blockscout" is off in Settings.
</p>
</div>
<div id="send-token-static" class="text-sm hidden"></div>
</div>
<div class="mb-2">
@@ -660,6 +714,13 @@
phishing/scam. Do not send funds to this address.
</div>
</div>
<p
id="confirm-etherscan-note"
class="mb-2 text-xs text-muted hidden"
>
This address was not checked on Etherscan, because "Check
recipients on Etherscan" is off in Settings.
</p>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
@@ -750,6 +811,7 @@
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="wait-tx-summary" class="font-bold"></div>
<div id="wait-tx-token-note"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
@@ -796,6 +858,7 @@
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="error-tx-summary" class="font-bold"></div>
<div id="error-tx-token-note"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
@@ -917,6 +980,23 @@
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Passwords</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="settings-allow-weak-passwords"
/>
Allow weak passwords
</label>
<p class="text-xs text-muted">
Lets you choose a password shorter than 12 characters
when you add a wallet.
</p>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Tracked Tokens</h3>
<p class="text-xs text-muted mb-2">
@@ -1002,9 +1082,21 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Blockscout API</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-use-blockscout" />
Use Blockscout
</label>
<p class="text-xs text-muted mb-2">
Shows transaction history, transaction details and the
tokens you have not tracked. Sends your addresses, and
the hash of each transaction you open, to the Blockscout
API below.
</p>
<p class="text-xs text-muted mb-1">
Used to fetch token balances and transaction history.
Change this if you run your own Blockscout instance.
Change the URL below if you run your own Blockscout
instance.
</p>
<input
type="text"
@@ -1019,6 +1111,23 @@
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Etherscan</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="settings-check-recipients-on-etherscan"
/>
Check recipients on Etherscan
</label>
<p class="text-xs text-muted">
Sends each recipient's address to Etherscan to check it
for a phishing or scam label.
</p>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Token Spam Protection</h3>
<p class="text-xs text-muted mb-2">
@@ -1291,6 +1400,13 @@
>
&nbsp;
</div>
<p
id="delete-address-tokens-note"
class="text-xs text-muted mb-2 hidden"
>
Only tracked tokens are listed, because "Use Blockscout" is
off in Settings.
</p>
<p class="text-xs text-muted mb-3">
A wallet always keeps at least one address. To remove the
last one, delete the whole wallet from Settings instead.
@@ -1504,6 +1620,16 @@
</div>
</div>
<!-- Shown in place of the block, decoded details, network
details and raw data, which only Blockscout gives. -->
<p
id="tx-detail-blockscout-note"
class="text-xs text-muted mx-1 mb-3 hidden"
>
This screen shows no block, decoded details, network details
or raw data, because "Use Blockscout" is off in Settings.
</p>
<!-- ── Decoded details ── -->
<div id="tx-detail-calldata-section" class="hidden">
<div class="bg-well p-3 mx-1 mb-3">
@@ -1868,10 +1994,11 @@
>Erasing the saved data deletes every wallet stored in
this browser.</strong
>
Nothing on the blockchain changes and no money is moved, but
without the exported copy above, or the recovery phrase for
each wallet written down, everything they hold is gone
forever.
Nothing on the blockchain changes and no money is moved. If
you kept a copy of a wallet's recovery phrase, private key
or extended private key, you can add that wallet again.
Without that copy or the exported copy above, everything the
wallet holds is gone forever.
</p>
<p class="text-xs mb-1">
To confirm, type
+116 -10
View File
@@ -5,12 +5,14 @@ const {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { refreshBalances, dropUntrackedTokens } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const {
$,
showView,
@@ -22,6 +24,7 @@ const {
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
onStorageChanged,
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
@@ -68,23 +71,65 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh.
let refreshTimer = null;
// The refresh first takes the stored network, "Use Blockscout" and "Check
// recipients on Etherscan" (followStoredSettings() below). So the screens it
// draws reflect any change saved elsewhere.
//
// The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// reaches this page only after the stored record (followStoredSettings()).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
try {
await followStoredSettings();
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
refreshPrices(),
refreshPrices(pageClosed.signal),
refreshBalances(
state.wallets,
wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
state.networkId,
networkId,
pageClosed.signal,
),
]);
state.lastBalanceRefresh = Date.now();
await saveState();
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
}
}
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
const got = refreshed.get(addr.address);
if (!got) continue;
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
@@ -116,6 +161,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
restoreView,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -183,6 +229,47 @@ function fallbackView() {
showView("main");
}
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
//
// Another page may change "Use Blockscout" or "Check recipients on Etherscan".
// This page takes either change the same way and draws its screens again. This
// page's own changes to the two settings are made under the lock too
// (views/settings.js, views/welcome.js).
async function followStoredSettings() {
let changed = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId !== state.networkId) {
settings.cutViewStack();
takeStoredNetwork(stored);
clearPopupCaches();
changed = true;
}
if (stored.useBlockscout !== state.useBlockscout) {
settings.setUseBlockscout(stored.useBlockscout);
changed = true;
}
if (
stored.checkRecipientsOnEtherscan !==
state.checkRecipientsOnEtherscan
) {
state.checkRecipientsOnEtherscan =
stored.checkRecipientsOnEtherscan;
changed = true;
}
if (!changed) return false;
}).catch(() => {});
if (changed) settings.showNewSettings();
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
@@ -194,9 +281,9 @@ function fallbackView() {
// 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
// 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
// show in the toolbar popup, whose window is the user's browser window; that
// one is left alone.
// no resize event, and the page stops there. An approval can also show in the
// toolbar popup, whose window is the user's browser window; that one is left
// alone.
async function widenApprovalWindow() {
const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return;
@@ -327,8 +414,27 @@ async function init() {
settingsAddToken.init(ctx);
deleteAddress.init(ctx);
// This page follows a record only when its network, "Use Blockscout" or
// "Check recipients on Etherscan" differs from this page's. A record
// written by this page's own save matches this page. A record naming no
// network is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (
written.networkId !== state.networkId ||
written.useBlockscout !== state.useBlockscout ||
written.checkRecipientsOnEtherscan !==
state.checkRecipientsOnEtherscan
) {
followStoredSettings();
}
});
// A change saved since loadState() above, before there was a listener.
followStoredSettings();
if (!state.hasWallet) {
showView("welcome");
welcome.show();
} else {
renderWalletList();
restoreView();
+10 -5
View File
@@ -38,11 +38,16 @@ function markViewRendered(view) {
if (view) renderedViews.add(view);
}
// Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one when the user switches the network, because every screen
// then on the page shows the previous network (views/settings.js); the
// unit tests, which simulate several page loads against one module
// instance, ask for one too.
// Begin a fresh page-load scope. The popup gets one by being loaded. It also
// asks for one when every screen on the page is out of date:
//
// - after a network switch, since those screens show the previous network
// (showNewSettings() in views/settings.js)
// - after "Use Blockscout" or "Check recipients on Etherscan" changes, here
// or on another page, since those screens were drawn under the old setting
//
// The unit tests ask for one too. They simulate several page loads against one
// module instance.
function resetRenderedViews() {
renderedViews.clear();
}
+2 -1
View File
@@ -132,7 +132,8 @@ function validatePassword() {
showError("add-wallet-password-error", "Please choose a password.");
return null;
}
if (pw.length < 12) {
// "Allow weak passwords" in Settings lifts the 12-character minimum.
if (!state.allowWeakPasswords && pw.length < 12) {
showError(
"add-wallet-password-error",
"Password must be at least 12 characters.",
+55 -15
View File
@@ -4,6 +4,7 @@ const {
showFlash,
balanceLinesForAddress,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle,
escapeHtml,
displaySymbol,
@@ -21,6 +22,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -83,6 +85,8 @@ function show() {
ctx.showAddressToken();
});
});
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
@@ -95,6 +99,7 @@ let loadedTxs = [];
let ensNameMap = new Map();
async function loadTransactions(address) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -111,43 +116,74 @@ async function loadTransactions(address) {
});
const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
names = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
// The rows then show no ENS names.
}
}
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
@@ -155,6 +191,10 @@ async function loadTransactions(address) {
function renderTransactions(txs) {
const list = $("tx-list");
if (!state.useBlockscout) {
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
return;
}
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -229,7 +269,7 @@ function init(_ctx) {
}
$("send-to").value = "";
$("send-amount").value = "";
$("send-token").classList.remove("hidden");
$("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
updateSendBalance();
resetSendValidation();
+56 -16
View File
@@ -7,6 +7,7 @@ const {
showFlash,
flashCopyFeedback,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle,
escapeHtml,
displaySymbol,
@@ -28,6 +29,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -68,7 +70,7 @@ function show() {
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(symbol);
price = getPrice(tokenId);
}
currentSymbol = symbol;
@@ -169,6 +171,7 @@ function show() {
}
async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -185,16 +188,6 @@ async function loadTransactions(address, tokenId) {
});
let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only
if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null);
@@ -206,32 +199,75 @@ async function loadTransactions(address, tokenId) {
);
}
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
names = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
// The rows then show no ENS names.
}
}
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
@@ -239,6 +275,10 @@ async function loadTransactions(address, tokenId) {
function renderTransactions(txs) {
const list = $("address-token-tx-list");
if (!state.useBlockscout) {
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
return;
}
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -324,7 +364,7 @@ function init(_ctx) {
$("send-token").value = tokenId;
}
// Hide dropdown, show static token display
$("send-token").classList.add("hidden");
$("send-token-dropdown").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
+29 -11
View File
@@ -11,6 +11,7 @@ const {
onViewLeave,
formatFee,
tokenLabel,
blockscoutOffTokenNoteHtml,
tokenSourcesByChainId,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
@@ -54,7 +55,7 @@ const {
} = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const { runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
@@ -401,6 +402,10 @@ function showTxApproval(details) {
if (d.address) {
if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
detailsHtml += blockscoutOffTokenNoteHtml(
d.address,
approvedTx.chainId,
);
}
detailsHtml += approvalAddressHtml(
d.address,
@@ -623,10 +628,7 @@ function permitWarningHtml(types, primaryType, domain, message) {
return "";
}
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const sources = tokenSourcesByChainId();
const spender = addressOrNull(
declaredField(types, primaryType, message, "spender")?.value,
);
@@ -655,6 +657,7 @@ function permitWarningHtml(types, primaryType, domain, message) {
html += `<div class="mb-2"><div>Token</div>`;
if (token) {
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
html += blockscoutOffTokenNoteHtml(token);
html += approvalAddressHtml(token);
} else {
html += `<div>Unknown</div>`;
@@ -1139,12 +1142,21 @@ function init(_ctx) {
}
});
$("btn-reject-tx").addEventListener("click", () => {
notify({
// The window closes only once the background has the rejection. A message
// 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",
id: approvalId,
approved: false,
});
}).catch(() => null);
if (response && response.error) {
showError("approve-tx-error", response.error);
return;
}
window.close();
});
@@ -1267,12 +1279,18 @@ function init(_ctx) {
if (outcome.retryable) setSignButtonBusy(false);
});
$("btn-reject-sign").addEventListener("click", () => {
notify({
// As for the transaction's Reject above. After Sign, the background
// refuses a rejection while the signature is being checked.
$("btn-reject-sign").addEventListener("click", async () => {
const response = await sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,
});
}).catch(() => null);
if (response && response.error) {
showError("approve-sign-error", response.error);
return;
}
window.close();
});
}
+7 -2
View File
@@ -129,7 +129,7 @@ function renderAmount(txInfo) {
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: 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;
$("confirm-amount").textContent = valueWithUsd(
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
@@ -157,7 +157,7 @@ function renderBalance(txInfo) {
$("confirm-balance").textContent = "unknown (" + symbol + ")";
return;
}
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const price = getPrice(txInfo.token, txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
@@ -334,6 +334,11 @@ function show(txInfo) {
$("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden";
$("confirm-etherscan-warning").style.visibility = "hidden";
// While "Check recipients on Etherscan" is off, there is no Etherscan
// warning to reserve space for. A line in its place says the check is off.
const etherscanOn = !!state.checkRecipientsOnEtherscan;
$("confirm-etherscan-warning").classList.toggle("hidden", !etherscanOn);
$("confirm-etherscan-note").classList.toggle("hidden", etherscanOn);
// Show burn warning via reserved element (in addition to inline warning)
if (isBurnAddress(txInfo.to)) {
+5
View File
@@ -119,6 +119,11 @@ function show(walletIdx, addrIdx) {
$("delete-address-recovery").textContent = recoveryPathText(wallet);
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
// Only Blockscout reports the tokens the user has not tracked.
$("delete-address-tokens-note").classList.toggle(
"hidden",
!!state.useBlockscout,
);
setFlash("");
showView("delete-address-confirm");
+2 -1
View File
@@ -16,6 +16,7 @@ const {
broadcastActiveChanged,
} = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const welcome = require("./welcome");
let deleteWalletIndex = null;
let lostPasswordIndex = null;
@@ -135,7 +136,7 @@ async function finishDelete(walletIdx) {
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
welcome.show();
return;
}
+54 -9
View File
@@ -21,7 +21,10 @@ const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const {
resolveTokenDecimals,
resolveTokenSymbol,
} = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
@@ -306,10 +309,16 @@ function nativeCurrency() {
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
//
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const net = chainId ? networkByChainId(chainId) : currentNetwork();
if (net && net.id === state.networkId) {
return { trackedTokens: state.trackedTokens, wallets: state.wallets };
return {
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
@@ -317,11 +326,39 @@ function tokenSourcesByChainId(chainId) {
};
}
// The symbol shown for a token line on the network with `chainId`, or on the
// active network when none is given: from the bundled list, the tokens the
// user tracks, and the explorer's report, the sources the amount line's scale
// comes from. Null when no source names one, so the token lines keep saying
// `Unknown token` for a token nothing knows.
// The line shown under a token while "Use Blockscout" is off. The token gets
// the line only when the bundled list and the tracked tokens lack its name or
// decimals. Otherwise this returns "". The token is a contract address on the
// network with `chainId`, or on the active network when none is given. "ETH"
// and null are no contract, and get "". A token that is not text also gets "":
// WaitTx and ErrorTx pass a token restored from storage unchecked.
function blockscoutOffTokenNoteHtml(token, chainId) {
if (typeof token !== "string") return "";
if (state.useBlockscout || !token || token === "ETH") return "";
const sources = tokenSourcesByChainId(chainId);
if (
resolveTokenSymbol(token, sources) !== null &&
resolveTokenDecimals(token, sources) !== null
) {
return "";
}
return (
'<div class="text-xs text-muted">Blockscout\'s report of your balances' +
' is not used to name this token, because "Use Blockscout" is off in' +
" Settings.</div>"
);
}
// The symbol shown for a token line, from these sources in order:
//
// - the bundled list
// - the tokens the user tracks
// - the explorer's report, while "Use Blockscout" is on
//
// The network is the one with `chainId`, or the active network when none is
// given. The amount line's scale comes from the same sources. Null when no
// source names one, so the token lines keep saying `Unknown token` for a token
// nothing knows.
function tokenLabel(address, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
}
@@ -402,7 +439,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
html += balanceLine(
t.symbol,
bal,
getPrice(t.symbol),
getPrice(t.address),
t.address.toLowerCase(),
);
seen.add(t.address.toLowerCase());
@@ -413,7 +450,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
html += balanceLine(
t.symbol,
0,
getPrice(t.symbol),
getPrice(t.address),
t.address.toLowerCase(),
);
}
@@ -534,6 +571,12 @@ function addressTitle(address, wallets) {
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// The line shown in place of the transaction history on Home, AddressDetail and
// AddressToken while "Use Blockscout" is off. That history comes from
// Blockscout alone.
const HISTORY_OFF_TEXT =
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
// The last two lines of a transaction history row: the counterparty's colour
// dot and name beside the amount, then its full address. A contract creation
// the user sent has no counterparty (its `to` is ""), so its row has the
@@ -791,6 +834,7 @@ module.exports = {
unknownableAmount,
nativeCurrency,
tokenSourcesByChainId,
blockscoutOffTokenNoteHtml,
tokenLabel,
formatFee,
addressDotHtml,
@@ -799,6 +843,7 @@ module.exports = {
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
HISTORY_OFF_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
+42 -7
View File
@@ -7,6 +7,7 @@ const {
timeAgo,
addressDotHtml,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle,
escapeHtml,
displaySymbol,
@@ -43,6 +44,7 @@ const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log");
function findActiveAddr() {
@@ -100,6 +102,10 @@ let homeTxs = [];
function renderHomeTxList(ctx) {
const list = $("home-tx-list");
if (!list) return;
if (!state.useBlockscout) {
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
return;
}
if (homeTxs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -178,6 +184,7 @@ async function loadHomeTxs(ctx) {
}
if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
@@ -197,6 +204,15 @@ async function loadHomeTxs(ctx) {
);
const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set();
let merged = [];
@@ -210,14 +226,24 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (filtered.newFraudContracts.length > 0) {
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
}
await saveState();
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
merged = filtered.transactions;
@@ -229,6 +255,13 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list");
if (list) {
list.innerHTML =
@@ -300,6 +333,8 @@ function walletListHtml() {
function render(ctx) {
const container = $("wallet-list");
// Only Blockscout reports the tokens the user has not tracked.
$("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
@@ -447,7 +482,7 @@ function init(ctx) {
}
$("send-to").value = "";
$("send-amount").value = "";
$("send-token").classList.remove("hidden");
$("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr);
updateSendBalance();
+12 -7
View File
@@ -10,6 +10,7 @@ const {
renderAddressHtml,
attachCopyHandlers,
goBack,
tokenSourcesByChainId,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
let ctx;
@@ -141,6 +142,8 @@ function updateToValidation() {
}
function renderSendTokenSelect(addr) {
// Only Blockscout reports the tokens the user has not tracked.
$("send-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
const sel = $("send-token");
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
const fraudSet = new Set(
@@ -185,7 +188,9 @@ function renderSendTokenSelect(addr) {
// so the transfer is encoded with the number the user read rather than with
// whatever the contract answers at signing time (see
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
// fetch time and cannot consult a state it is in the middle of replacing.
// fetch time and cannot consult a state it is in the middle of replacing. As on
// the approval screens, tokenSourcesByChainId() gives the wallets only while
// "Use Blockscout" is on.
//
// The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to
@@ -198,10 +203,7 @@ function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
if (!tb) return { tokenBalance: "0", tokenDecimals };
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals };
@@ -401,8 +403,11 @@ function init(_ctx) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to);
// getProvider() does not look up a name resolved off-chain,
// so such a name comes back with no address too. The message
// covers both, in the one line the flash area shows.
if (!resolved) {
showFlash("That ENS name has no address.");
showFlash("That ENS name has no on-chain address.");
return;
}
resolvedTo = resolved;
@@ -426,7 +431,7 @@ function init(_ctx) {
});
$("btn-send-back").addEventListener("click", () => {
$("send-token").classList.remove("hidden");
$("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
goBack();
});
+169 -35
View File
@@ -17,6 +17,7 @@ const {
const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { restorableStack } = require("../../shared/persistedState");
const { dropUntrackedTokens } = require("../../shared/balances");
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const {
log,
@@ -38,9 +39,17 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
// The network that Settings was last drawn for. A follow of a switch saved
// elsewhere sets `state` to the new network first. Settings is drawn again only
// once the follow's write returns. So both "Save" buttons take this network,
// not the one in `state`.
let shownNetwork = null;
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
@@ -196,9 +205,14 @@ function renderWalletListSettings() {
}
function show() {
shownNetwork = currentNetwork();
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId;
// Set on every show, not only at init: Welcome can change these two.
$("settings-use-blockscout").checked = state.useBlockscout;
$("settings-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
renderTrackedTokens();
renderSiteLists();
renderWalletListSettings();
@@ -243,7 +257,106 @@ async function renderSiteLists() {
);
}
function init(ctx) {
// A network switch, made by the user here or saved by the background once the
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. Two functions leave the page as
// README's Screen Map describes:
//
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
// a popup closed straight after
// - showNewSettings(), once `state` holds the new network
//
// The stack is cut at the first screen that cannot be rendered from saved
// state, such as Send or AddToken, and at AddressToken unless it shows the
// native token: any other token there was picked on the previous network, so
// it is no longer selected either. Without a wallet the stack starts at
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
// A change to "Use Blockscout" cuts the stack the same way (setUseBlockscout()
// below).
function cutViewStack() {
if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1) {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.selectedToken = null;
}
state.viewStack = restorableStack(state.viewStack, state.currentView);
}
// Sets "Use Blockscout" to `on` on this page. It is called inside a save,
// holding the lock, for the user's change here or another page's
// (followStoredSettings() in src/popup/index.js).
//
// Send's token dropdown was filled under the old setting, and Back cannot draw
// Send again. So the stack is cut, as for a network switch. Turning the setting
// off also drops every token the user has not tracked.
function setUseBlockscout(on) {
state.useBlockscout = on;
cutViewStack();
if (!on) dropUntrackedTokens(state.wallets, state.trackedTokens);
}
// Draws the page again after a network switch, or after another page changed
// "Use Blockscout" or "Check recipients on Etherscan". Every screen counts as
// not yet rendered, so "Back" renders again any screen that a reopened popup
// can render. Settings on top is brought up to date in place. Any other screen
// is drawn again as a reopened popup draws it, which is Home for one that
// cannot be rendered from saved state. Without a wallet any other screen is
// Welcome or AddWallet. Back only unhides Welcome, so its two checkboxes are
// set here.
function showNewSettings() {
resetRenderedViews();
$("welcome-use-blockscout").checked = state.useBlockscout;
$("welcome-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
if (state.currentView === "settings") {
markViewRendered("settings");
shownNetwork = currentNetwork();
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
$("settings-use-blockscout").checked = state.useBlockscout;
$("settings-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
renderTrackedTokens();
} else if (state.hasWallet) {
ctx.restoreView();
}
updateDebugBanner(state.currentView);
}
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
// `networkId`, the one that Settings showed when the user tapped "Save". The
// URL is set in `state` inside the save, holding the lock. So the page cannot
// follow a switch saved elsewhere (followStoredSettings() in
// src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
async function saveEndpoint(field, url, networkId) {
let refused = false;
await saveState((stored) => {
refused =
state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false;
state[field] = url;
stored[field] = url;
});
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else {
showFlash("Saved.");
}
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx);
showPhrase.init();
@@ -253,6 +366,8 @@ function init(ctx) {
showFlash("Please enter an RPC URL.");
return;
}
// The URL is for the network that Settings shows when "Save" is tapped.
const net = shownNetwork;
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url, {
@@ -271,7 +386,6 @@ function init(ctx) {
showFlash("Endpoint returned an error.");
return;
}
const net = currentNetwork();
if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + ".");
return;
@@ -287,9 +401,7 @@ function init(ctx) {
showFlash("Could not reach endpoint.");
return;
}
state.rpcUrl = url;
await saveState();
showFlash("Saved.");
await saveEndpoint("rpcUrl", url, net.id);
});
$("btn-save-blockscout").addEventListener("click", async () => {
@@ -298,6 +410,7 @@ function init(ctx) {
showFlash("Please enter a Blockscout API URL.");
return;
}
const net = shownNetwork;
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url + "/stats");
@@ -312,44 +425,52 @@ function init(ctx) {
showFlash("Could not reach endpoint.");
return;
}
state.blockscoutUrl = url;
await saveState();
showFlash("Saved.");
await saveEndpoint("blockscoutUrl", url, net.id);
});
// Each change is made inside the save, holding the lock. So
// followStoredSettings() in src/popup/index.js never mistakes this page's
// unsaved change for one that another page saved.
//
// The screens under Settings were drawn under the old setting. So each
// change makes every screen but Settings count as not yet drawn, as a
// network switch does. Back then draws again any screen that a reopened
// popup can draw. Back only unhides Welcome, so its boxes are set here too.
$("settings-use-blockscout").addEventListener("change", async () => {
const on = $("settings-use-blockscout").checked;
await saveState(() => setUseBlockscout(on));
$("welcome-use-blockscout").checked = on;
resetRenderedViews();
markViewRendered("settings");
});
$("settings-check-recipients-on-etherscan").addEventListener(
"change",
async () => {
const on = $("settings-check-recipients-on-etherscan").checked;
await saveState(() => {
state.checkRecipientsOnEtherscan = on;
});
$("welcome-check-recipients-on-etherscan").checked = on;
resetRenderedViews();
markViewRendered("settings");
},
);
const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value;
if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
cutViewStack();
const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showNewSettings();
showFlash("Switched to " + net.name + ".");
});
@@ -412,6 +533,12 @@ function init(ctx) {
await saveState();
});
$("settings-allow-weak-passwords").checked = state.allowWeakPasswords;
$("settings-allow-weak-passwords").addEventListener("change", async () => {
state.allowWeakPasswords = $("settings-allow-weak-passwords").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(
@@ -484,4 +611,11 @@ function init(ctx) {
});
}
module.exports = { init, show, renderSiteLists };
module.exports = {
init,
show,
renderSiteLists,
cutViewStack,
setUseBlockscout,
showNewSettings,
};
-2
View File
@@ -143,8 +143,6 @@ async function resetProfile() {
try {
// Under the lock every write of the stored record takes, so the erase
// cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask"));
} catch (e) {
log.errorf("state recovery: reset failed:", e);
+15 -1
View File
@@ -26,6 +26,7 @@ const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log");
const { storedSettingIsOn } = require("../../shared/storedSettings");
const { decodeCalldata } = require("./approval");
/**
@@ -172,7 +173,8 @@ function render() {
}
}
// Hide calldata and raw data sections; always fetch full tx details
// Hide the calldata and raw data sections. loadFullTxDetails() shows them
// once it has their data.
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
@@ -191,6 +193,12 @@ function render() {
if (el) el.classList.add("hidden");
}
// The block, network details, decoded details and raw data come from
// Blockscout. While the setting is off, this line says why none is shown.
$("tx-detail-blockscout-note").classList.toggle(
"hidden",
!!state.useBlockscout,
);
loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp);
@@ -303,7 +311,12 @@ function populateOnChainDetails(txData, chainId) {
}
}
// Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown.
async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -319,6 +332,7 @@ async function loadFullTxDetails(txHash, chainId) {
);
if (!resp.ok) return;
const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return;
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId);
+15 -3
View File
@@ -14,6 +14,7 @@ const {
displaySymbol,
clearViewStack,
tokenLabel,
blockscoutOffTokenNoteHtml,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
@@ -108,6 +109,10 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
txInfo.token,
txInfo.chainId,
);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
@@ -215,9 +220,10 @@ function showWait(txInfo, txHash) {
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// unchecked: they are compared and coalesced rather than dereferenced, and
// tokenSymbol is null for ETH.
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
// Everything else only compares and coalesces them, and tokenSymbol is null for
// ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -282,6 +288,7 @@ function decodedDetailsHtml(decoded, chainId) {
if (d.isToken) {
const sym = tokenLabel(d.address, chainId) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += blockscoutOffTokenNoteHtml(d.address, chainId);
html += toAddressHtml(d.address, chainId);
} else {
html += toAddressHtml(d.address, chainId);
@@ -345,6 +352,7 @@ function showError(txInfo, txHash, message) {
state.viewData = {
amount: txInfo.amount,
symbol: symbol,
token: txInfo.token,
to: txInfo.to,
hash: txHash || null,
message: message,
@@ -358,6 +366,10 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
d.token,
d.chainId,
);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
+34 -4
View File
@@ -1,7 +1,37 @@
const { $ } = require("./helpers");
// The first screen, shown while there is no wallet. It offers "Use Blockscout"
// and "Check recipients on Etherscan", which Settings offers too. Both are off
// until the user ticks them.
function init(ctx) {
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
const { $, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state");
function show() {
$("welcome-use-blockscout").checked = state.useBlockscout;
$("welcome-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
showView("welcome");
}
module.exports = { init };
// Each change is made inside the save, holding the lock, as Settings makes it.
function init(ctx) {
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
$("welcome-use-blockscout").addEventListener("change", async () => {
const on = $("welcome-use-blockscout").checked;
await saveState(() => {
state.useBlockscout = on;
});
});
$("welcome-check-recipients-on-etherscan").addEventListener(
"change",
async () => {
const on = $("welcome-check-recipients-on-etherscan").checked;
await saveState(() => {
state.checkRecipientsOnEtherscan = on;
});
},
);
}
module.exports = { init, show };
+171 -21
View File
@@ -1,5 +1,10 @@
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.
// Balance fetching:
//
// - ETH balances, and the balances of the tokens the user tracks, via RPC
// - the other ERC-20 token balances and the holder counts via Blockscout,
// while the user has "Use Blockscout" on. Those balances are dropped when
// the setting is off.
// - ENS reverse lookup via RPC
const {
JsonRpcProvider,
@@ -17,6 +22,7 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount");
const { storedSettingIsOn } = require("./storedSettings");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -36,9 +42,18 @@ const { resolveTokenDecimals } = require("./approvalAmount");
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
//
// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. A resolver can
// say a name is resolved off-chain and give a web address for it. By default
// ethers then fetches that address straight from the browser, sending the name
// being looked up. For the reverse lookup of the user's addresses, the name sent
// is the user's own (https://git.eeqj.de/sneak/AutistMask/issues/668). With the
// flag set, such a name does not resolve.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
provider.disableCcipRead = true;
return provider;
}
function requireNetworkId(networkId) {
@@ -80,8 +95,16 @@ function rawUnits(value) {
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }].
//
// While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
// that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way this returns an empty list, so
// only tracked tokens are listed.
//
// Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders.
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
@@ -96,6 +119,7 @@ async function fetchTokenBalances(
trackedTokens,
signal,
) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
try {
const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances",
@@ -105,6 +129,7 @@ async function fetchTokenBalances(
return null;
}
const items = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return [];
if (!Array.isArray(items)) return null;
const trackedSet = new Set(
@@ -205,8 +230,96 @@ async function fetchTokenBalances(
}
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// The entries at `address` of the tokens the user tracks, shaped as
// fetchTokenBalances() returns them, for the tokens the address holds.
//
// The balance is read with balanceOf through the RPC endpoint. The name and
// symbol are the tracked token's. `decimals` and `holders` are those of
// Blockscout's entry for the token in `explorerRequest`, this refresh's
// fetchTokenBalances() for the address; while Blockscout fails they are kept
// from the token's entry in `previous`, the address's entries before this
// refresh. The balance is stated at the scale the screens read the token at
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
// records, then Blockscout's, as in fetchTokenBalances().
//
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
// balance keeps its figure, or takes Blockscout's entry from this refresh when
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
//
// `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
listed.map(async (token) => {
try {
const contract = new Contract(
token.address,
ERC20_ABI,
provider,
);
return await contract.balanceOf(address);
} catch (e) {
if (!signal?.aborted) {
log.errorf(
"token balance failed",
address,
token.address,
e.shortMessage || e.message,
);
}
return null;
}
}),
);
const explorerRows = await explorerRequest;
const reported = explorerRows ?? previous;
const rows = [];
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
}
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
name: token.name || "",
symbol: token.symbol || "???",
decimals: decimals,
// null when nothing knows the scale: a holding whose amount
// cannot be stated, never a zero.
balance: scale === null ? null : formatTokenBalance(raw, scale),
holders: entry ? entry.holders : null,
});
});
return rows;
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
// for fetchTokenBalances().
async function refreshBalances(
wallets,
rpcUrl,
@@ -217,6 +330,8 @@ async function refreshBalances(
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = [];
for (const wallet of wallets) {
@@ -266,23 +381,43 @@ async function refreshBalances(
}),
);
// ERC-20 token balances via Blockscout
// ERC-20 token balances: the tracked tokens' through the RPC
// endpoint, and from Blockscout the tokens the user has not
// tracked. When Blockscout fails, the untracked rows from before
// this refresh stay. While "Use Blockscout" is off, there are no
// untracked rows. The Blockscout request is started first and
// handed to fetchTrackedTokenBalances() too, which waits for it
// after sending its balanceOf reads, so the two run side by side.
const previous = addr.tokenBalances || [];
const explorerRequest = fetchTokenBalances(
addr.address,
blockscoutUrl,
tracked,
signal,
);
updates.push(
fetchTokenBalances(
addr.address,
blockscoutUrl,
trackedTokens,
signal,
).then((balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
log.debugf(
"Token balances",
addr.address,
balances.length,
"tokens",
);
}
Promise.all([
fetchTrackedTokenBalances(
provider,
addr.address,
tracked,
previous,
explorerRequest,
signal,
),
explorerRequest,
]).then(([trackedRows, explorerRows]) => {
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances = trackedRows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
addr.tokenBalances.length,
"tokens",
);
}),
);
}
@@ -292,6 +427,20 @@ async function refreshBalances(
log.debugf("refreshBalances done");
}
// Drops the balance of each token not in `trackedTokens` from every address.
// Callers run it while "Use Blockscout" is off, since only Blockscout reports
// those tokens.
function dropUntrackedTokens(wallets, trackedTokens) {
const tracked = new Set(trackedTokens.map((t) => t.address.toLowerCase()));
for (const wallet of wallets) {
for (const addr of wallet.addresses || []) {
addr.tokenBalances = (addr.tokenBalances || []).filter((row) =>
tracked.has(String(row.address).toLowerCase()),
);
}
}
}
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
// `signal` is as for fetchTokenBalances().
@@ -410,6 +559,7 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
module.exports = {
fetchTokenBalances,
refreshBalances,
dropUntrackedTokens,
lookupTokenInfo,
getProvider,
scanForAddresses,
+14
View File
@@ -224,6 +224,19 @@ function storageRemove(keys) {
return Promise.resolve(storage.remove(keys));
}
/**
* Call `listener` after every write to `storage.local`, whether this page,
* another page or the background made it, with what the write changed, as the
* browser reports it: `{ [key]: { oldValue, newValue } }`.
*
* @param {function(Object): void} listener
*/
function onStorageChanged(listener) {
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
if (areaName === "local") listener(changes);
});
}
/**
* Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
@@ -385,6 +398,7 @@ module.exports = {
getPlatformInfo,
hasBrowserNamespace,
notify,
onStorageChanged,
runtimeApi,
sendMessage,
sessionStorageGet,
+55 -17
View File
@@ -1,8 +1,18 @@
// Consolidated chain-switch handler for the popup.
// The popup's network switch.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, future chain additions) all go
// through a single code path.
// A network switch reaches the popup's `state` in one of two ways:
//
// - onChainSwitch() below makes the switch the user picks in Settings, with
// applyChainSwitchFields() (chainSwitchFields.js).
// - followStoredSettings() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js).
//
// Both change `state` inside a save, holding the lock, and both reset the
// popup's own caches through clearPopupCaches(), so a reset added there
// reaches both. A field applyChainSwitchFields() sets has to be copied by
// copyChainSwitchFields() too.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
@@ -20,22 +30,50 @@
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain.
// Returns the network configuration object for the new chain, or null when
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
//
// The save writes only the fields that differ from what this page last saved.
// When this page last saved a balance of "0", clearing that balance is no
// change. So the switch also resets these fields in the stored record:
//
// - every address's balance and token balances;
// - the time of the last balance refresh.
//
// A balance that the background or another page stored since this page's last
// save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = applyChainSwitchFields(state, newNetworkId);
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
let net = null;
await saveState((stored) => {
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
clearPopupCaches();
});
return net;
}
module.exports = { onChainSwitch };
module.exports = { onChainSwitch, clearPopupCaches };
+31 -2
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the object it is
// Everything here is synchronous and touches nothing but the objects it is
// given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back.
@@ -76,4 +76,33 @@ function applyChainSwitchFields(s, newNetworkId) {
return net;
}
module.exports = { applyChainSwitchFields };
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
// the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in
// src/shared/state.js). Balances are taken by address, and an address
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens;
s.trackedTokens = switched.trackedTokens;
s.networkId = switched.networkId;
s.rpcUrl = switched.rpcUrl;
s.blockscoutUrl = switched.blockscoutUrl;
s.lastBalanceRefresh = switched.lastBalanceRefresh;
s.tokenHolderCache = switched.tokenHolderCache;
s.fraudContracts = switched.fraudContracts;
const stored = new Map();
for (const wallet of switched.wallets) {
for (const addr of wallet.addresses) stored.set(addr.address, addr);
}
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
}
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
+11
View File
@@ -3,6 +3,7 @@
// This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks");
const { storedSettingIsOn } = require("./storedSettings");
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
@@ -78,12 +79,19 @@ function parseEtherscanPage(html) {
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia.
*
* While the stored "Check recipients on Etherscan" is off, Etherscan is not
* asked. An answer that lands after the setting went off is dropped
* (src/shared/storedSettings.js). Either way the answer is null.
*
* @param {string} address - Ethereum address to check.
* @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address, chainId) {
try {
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
return null;
}
// Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state");
@@ -94,6 +102,9 @@ async function checkEtherscanLabel(address, chainId) {
});
if (!resp.ok) return null;
const html = await resp.text();
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
return null;
}
const result = parseEtherscanPage(html);
if (result.isPhishing) {
return {
+18
View File
@@ -48,6 +48,17 @@ const DEFAULT_STATE = {
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
// "Use Blockscout" and "Check recipients on Etherscan" in Settings. Each
// setting applies to every network. Both are off until the user turns them
// on. With "Use Blockscout" on, Blockscout is sent the user's addresses and
// the hash of each transaction opened. With "Check recipients on
// Etherscan" on, Etherscan is sent the recipient's address and nothing
// else.
useBlockscout: false,
checkRecipientsOnEtherscan: false,
// "Allow weak passwords" in Settings. While it is off, AddWallet refuses a
// password shorter than 12 characters.
allowWeakPasswords: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
@@ -406,6 +417,13 @@ function normalizePersisted(saved) {
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
// Anything but true is off: each setting lets a request reach another
// service.
out.useBlockscout = saved.useBlockscout === true;
out.checkRecipientsOnEtherscan = saved.checkRecipientsOnEtherscan === true;
// Anything but true is off: the setting lets AddWallet accept a short
// password.
out.allowWeakPasswords = saved.allowWeakPasswords === true;
// A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
+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 { networkByChainId } = require("./networks");
const { log } = require("./log");
const PRICE_CACHE_TTL = 300000; // 5 minutes
// A reading older than its feed's heartbeat plus this many seconds is no price.
const STALE_MARGIN = 1800; // 30 minutes
const FEED_ABI = [
"function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)",
];
// USD prices: ether's under "ETH", each token's under its lowercased contract
// address.
const prices = {};
let lastFetchedAt = 0;
async function refreshPrices() {
// Testnet tokens have no real market value — skip price fetching
// and clear any stale mainnet prices so the UI shows no USD values.
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) {
// Reads the price of ether and of each token any address holds or the user
// tracks, when that token has a feed. Mainnet only. The reads go to the RPC
// endpoint as one batched request, at most every 5 minutes. A failed read is
// logged, and that token then has no price.
//
// `signal`, passed by the popup, is aborted when the popup closes. A read that
// fails once `signal` is aborted is not logged, because closing the popup
// cancelled it.
async function refreshPrices(signal) {
const { state, currentNetwork } = require("./state");
if (currentNetwork().id !== "mainnet") {
clearPrices();
return;
}
const now = Date.now();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
try {
const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched);
lastFetchedAt = now;
} catch {
// prices stay stale on error
const feeds = new Map([["ETH", ETH_USD_FEED]]);
for (const address of heldOrTrackedTokens(state)) {
const feed = PRICE_FEED_BY_ADDRESS.get(address);
if (feed) feeds.set(address, feed);
}
// 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
@@ -37,14 +111,15 @@ function clearPrices() {
lastFetchedAt = 0;
}
// Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet, on a chain id
// that names no network, or for a symbol with no price.
function getPrice(symbol, chainId) {
// Return the USD price of the token at the contract address `token`, or of
// ether when `token` is "ETH". The price is for the network with `chainId`, or
// for the active network when no chain id is given. Returns null on testnet,
// on a chain id that names no network, and for a token with no price.
function getPrice(token, chainId) {
const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (!network || network.isTestnet) return null;
return prices[symbol] || null;
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
}
function formatUsd(amount) {
@@ -62,7 +137,7 @@ function formatUsd(amount) {
// 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
// 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
@@ -99,8 +174,9 @@ function getAddressValue(addr) {
// nor make it incomplete. Anything that is not a number at all is not
// a holding this can price either, and is left to the same rule.
if (!(tokenBal > 0)) continue;
if (prices[token.symbol]) {
usd += tokenBal * prices[token.symbol];
const price = prices[token.address.toLowerCase()];
if (price) {
usd += tokenBal * price;
} else {
partial = true;
}
+41 -15
View File
@@ -23,6 +23,7 @@
// guarantee.
const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
@@ -125,8 +126,10 @@ function currentNetwork() {
}
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began. saveState() diffs
// the live state against this to find only the fields THIS page changed since.
// finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -466,12 +469,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
// before this fix; only the persisted record is guaranteed current. Three
// fields are the exceptions: the network, "Use Blockscout" and "Check
// recipients on Etherscan". Through `beforeWrite`, the popup takes a change to
// any of them that another context saved (followStoredSettings() in
// src/popup/index.js).
async function saveStateOnce(beforeWrite) {
const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this,
@@ -485,6 +488,12 @@ async function saveStateOnce() {
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask);
if (beforeWrite && beforeWrite(fresh) === false) return;
// A copy, so what this save compares and writes is the page's state as it
// stood once the stored record was read. A change made while it waits on
// the write is left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) {
@@ -572,17 +581,22 @@ function reportSaveFailure(err) {
// Each save runs start to finish holding the lock every write of the stored
// record takes (withLock() in src/shared/browserApi.js), so no write by
// another page or the background lands between its read and its write and is
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox
// private window is the exception: Firefox gives it locks of its own (README,
// "Stored state and its version").
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641).
//
// The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be
// asked for at once; they take the lock in the order they were asked, and each
// compares the page's state as it stands when its turn begins against the
// baseline the save before it left.
function saveState() {
const turn = withLock("autistmask", saveStateOnce);
// compares the page's state, as it stands once the stored record has been read
// and `beforeWrite` has run, against the baseline the save before it left.
//
// `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. It may also change the stored
// record. A field that this page did not change is then written as
// `beforeWrite` left it. When it returns false, nothing is written; any other
// return lets the save go ahead.
function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
@@ -590,6 +604,17 @@ function saveState() {
return turn;
}
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followStoredSettings() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -635,6 +660,7 @@ module.exports = {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
currentAddress,
currentNetwork,
+43
View File
@@ -0,0 +1,43 @@
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
// from the stored profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
// again when the answer lands. While the setting is off, no such request is
// made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just
// changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState");
// Whether the setting `name` is on in the stored profile. `name` is
// "useBlockscout" or "checkRecipientsOnEtherscan". The profile is read through
// normalizePersisted(), as a load reads it, so only a stored true is on. A
// failed read counts as off.
async function storedSettingIsOn(name) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask)[name];
} catch {
return false;
}
}
// Whether `networkId` is the active network in the stored profile. A failed
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+351 -31
View File
@@ -4,12 +4,12 @@
// Addresses are EIP-55 checksummed.
//
// 511 tokens.
//
// Below the list, USD_PRICE_FEEDS gives the Chainlink USD price feed of each
// token that has one.
const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
const TOKENS = [
{
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
@@ -3597,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.
function getTopTokens(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 "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase();
@@ -3690,7 +4008,9 @@ module.exports = {
TOKENS,
TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS,
ETH_USD_FEED,
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens,
getTopTokenPrices,
resolveSymbol,
};
+7
View File
@@ -21,6 +21,7 @@ const { toDecimals } = require("./transferAmount");
// detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap");
const { storedSettingIsOn } = require("./storedSettings");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
@@ -235,12 +236,17 @@ function mergeTransactions(txs, tokenTransfers) {
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
// carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
//
// While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
// that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way the answer is an empty list.
async function fetchRecentTransactions(
address,
blockscoutUrl,
chainId,
count = 25,
) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address);
@@ -271,6 +277,7 @@ async function fetchRecentTransactions(
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
if (!(await storedSettingIsOn("useBlockscout"))) return [];
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
+410 -111
View File
@@ -336,7 +336,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read, and so do the lines for what a step
// takes from the sender, when this decoder does not know it.
// takes from the sender, when this decoder does not know it, and for what a
// permit it could not read allows.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or
@@ -478,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
// 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`
@@ -494,17 +506,20 @@ function amountText(raw, info) {
};
}
// The line giving a moved token under `label`, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no address.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
// The line giving a moved token, or the token of a permit, under `label`, in
// the shape of the Token In line, so the screens show it as they show that
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
// ETH has no address.
function tokenMovedLine(label, info) {
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
value: info.symbol
? info.symbol + " (" + info.address + ")"
: info.address,
address: info.address,
isToken: true,
};
}
@@ -527,6 +542,32 @@ function decodePermit2(input) {
}
}
// Decode PERMIT2_PERMIT_BATCH (command 0x03) input bytes into the allowances
// it grants, one for each token it names, all for its one spender, in the
// shape decodePermit2() returns. Each release's
// `contracts/base/Dispatcher.sol`, at the commit linked for it above, hands
// Permit2 the batch in this shape.
// ABI: (((address token, uint160 amount, uint48 expiration, uint48 nonce)[],
// address spender, uint256 sigDeadline), bytes signature)
function decodePermit2Batch(input) {
try {
const d = coder.decode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
input,
);
return d[0][0].map((details) => ({
token: details[0],
amount: details[1],
spender: d[0][1],
}));
} catch {
return null;
}
}
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the
// check when `owner` holds at least `minBalance` of `token`, and reads `owner`
// as written, without map(), on every release that has the command:
@@ -596,12 +637,14 @@ function decodeV2SwapExactOut(input) {
// token(20) ...), or null when it is not one token followed by whole hops of
// a fee and a token (40 hex digits, then 46 per hop). The router reads only
// whole hops and ignores any bytes after the last one, so the last 20 bytes
// of such a path are not the token it reads there.
// of such a path are not the token it reads there. Both are checksummed, as
// the ABI decoder gives every other address, so a token reads the same on
// every line.
function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = "0x" + hex.slice(0, 40);
const last = "0x" + hex.slice(-40);
const first = getAddress("0x" + hex.slice(0, 40));
const last = getAddress("0x" + hex.slice(-40));
return { first, last };
}
@@ -923,7 +966,9 @@ function totalAmount(amounts) {
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// for amountOutMin and the actions that buy Token Out, except that there
// is none when a swap action in the step also spends Token Out: the step
// then does not pay out all that those actions buy.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
@@ -1157,11 +1202,13 @@ function decodeV4Swap(input) {
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
if (!spent.includes(tokenOut)) {
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
}
return {
@@ -1204,13 +1251,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
// Whether the user pays the V4 step that set the input side any of
// its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read.
// Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see paidByUser in the loop), or a WRAP_ETH,
// whose ETH comes from the transaction's value. A step that pays an
// ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read, or null (see
// permitStands after the loop).
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
// Every allowance a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH grants,
// before a step set the input side or after, in step order, as
// { token, amount, spender, step }. A permit or batch whose input
// this decoder cannot read is one with a null token, amount and
// spender. Each but a permit whose amount is the Amount figure gets
// lines of its own (see after the PERMIT2_TRANSFER_FROM lines).
const permits = [];
// The index of each step this decoder did not read in full: a
// command it does not read, a step whose input it cannot read, in
// whole or in part, and one it fails on (see permitStands).
@@ -1221,8 +1278,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let hasUnwrapWeth = false;
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
// which the router holds; cleared whenever the output side is set
// again.
// again, or a later step may pay that ETH out as WETH (see after the
// loop). `unwrapIntoRouterStep` is the index of the first such
// UNWRAP_WETH that may have paid the ETH to the router, which then
// holds it: its recipient is the router or cannot be read. A later
// UNWRAP_WETH does not replace it. It is null when there is none, and
// is cleared with `outputUnwrapped` when the output side is set again.
let outputUnwrapped = false;
let unwrapIntoRouterStep = null;
// The index of the step that set the output side.
let outputStep = -1;
// The index of the last step this decoder treats as one that may take
// WETH out of the router or pay anyone WETH: any step but one it read
// that is a PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP,
// TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of a token
// other than WETH, and not of ETH to a WETH contract, which pays the
// router WETH (see keepsRouterWeth in the loop). None of those can.
let lastWethStep = -1;
// Where the step that set the output side paid its output: the
// recipient of a swap step, as written, or of each TAKE, TAKE_ALL and
// TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender,
@@ -1253,8 +1325,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop. A PERMIT2_PERMIT sets no side here: after the
// loop, the last one read names Token In, and the Amount line is
// formed there, in one place (see permitStands).
// loop, one whose amount stands as the Amount figure names Token In,
// and the Amount line is formed there, in one place (see
// permitStands).
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -1265,19 +1338,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount, recipients) => {
const setOutput = (step, token, amount, recipients) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputEstablished = true;
outputUnwrapped = false;
unwrapIntoRouterStep = null;
outputRecipients = recipients;
outputStep = 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.
// 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 = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
@@ -1296,6 +1373,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const spentUnread = () => {
userPaid.push({ token: null, amount: null });
};
// A PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH at `step` whose input this
// decoder cannot read may let any spender take any amount of any
// token: one line says so, there is no Amount line, whose figure may
// then be too low, and the minimum reads Min. paid out (see
// toAnotherSpender).
const permitUnread = (step) => {
permits.push({ token: null, amount: null, spender: null, step });
stepsUnread.push(step);
};
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
@@ -1405,6 +1491,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
);
};
// Whether a SWEEP, TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION
// of `token` to `recipient` pays anyone WETH (see lastWethStep): it
// does when `token` is WETH, and when it pays ETH to a WETH contract,
// which pays the router WETH for it.
const paysWeth = (token, recipient) =>
WETH_ADDRESSES.includes(token.toLowerCase()) ||
(token === "0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(recipient.toLowerCase()));
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
@@ -1421,6 +1515,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
? COMMAND_NAMES[command]
: "Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
// Set by a branch below that reads a step that cannot take WETH
// out of the router or pay anyone WETH (see lastWethStep).
let keepsRouterWeth = false;
try {
if (command === "EXECUTE_SUB_PLAN") {
@@ -1437,23 +1534,44 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]);
if (p) {
permits.push({ ...p, step: i });
keepsRouterWeth = true;
} else {
permitUnread(i);
}
// Only a permit whose amount stands as the Amount figure
// names Token In (see permitStands). Every other permit,
// before the step that sets the input side or after it,
// leaves Token In and Amount as they read with no permit
// and gets lines of its own. One after a step set the input
// side never stands, so only one before it is kept here.
if (p && !inputEstablished) {
// Whether a step before it took its token, or may
// have: the step that set the input side or one in
// `userPaid`, when its token is the permit's or not
// known.
// have: one in `userPaid`, `movedIntoRouter` or
// `movedFromSender`, when its token is the permit's
// or not known. No step has set the input side yet.
const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token);
const tookBefore =
(inputEstablished && mayBeItsToken(inputToken)) ||
userPaid.some((t) => mayBeItsToken(t.token));
const tookBefore = [
...userPaid,
...movedIntoRouter,
...movedFromSender,
].some((t) => mayBeItsToken(t.token));
permit = {
...p,
step: i,
allowedToFail,
beforeEveryTake: !tookBefore,
};
}
} else if (command === "PERMIT2_PERMIT_BATCH") {
// Each allowance it grants gets the lines of a permit
// whose amount does not stand. It pays nobody.
const batch = decodePermit2Batch(inputs[i]);
if (batch) {
permits.push(...batch.map((p) => ({ ...p, step: i })));
} else {
stepsUnread.push(i);
permitUnread(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
@@ -1507,31 +1625,34 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// what the user receives: it sets no side, and the
// step says so.
commandNames[i] += " (of another address)";
keepsRouterWeth = true;
} else {
// The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees
// nothing and does not replace a minimum an earlier
// step stated. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. Any
// nothing. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. A zero
// check sets neither Token Out nor the minimum. Any
// other minBalance sets the output side as a swap
// does. It pays nobody.
// does. The check pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance, []);
if (minBalance !== 0n) {
setOutput(i, b.token, minBalance, []);
}
keepsRouterWeth = true;
}
} else if (command === "V3_SWAP_EXACT_IN") {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1548,10 +1669,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1579,10 +1701,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
setOutput(s.tokenOut, s.amountOut, [s.recipient]);
setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1604,6 +1727,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase());
}
keepsRouterWeth = !paysWeth(s.token, s.recipient);
} else {
payeeUnread = true;
stepsUnread.push(i);
@@ -1641,6 +1765,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) {
heldByRouter.delete(t.token.toLowerCase());
}
keepsRouterWeth = !paysWeth(t.token, t.recipient);
} else {
payeeUnread = true;
stepsUnread.push(i);
@@ -1672,10 +1797,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
if (!inputEstablished) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
inputPaidByUser = true;
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
@@ -1703,6 +1831,41 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own
// ETH, whichever payer the SETTLE names. The router holds
// the transaction's value, and any ETH an earlier step
// left it (see heldByRouter). So the user also pays a
// SETTLE of ETH that names the router as payer. The user
// pays all of its amount when the router holds no other
// ETH, and up to its amount otherwise. CONTRACT_BALANCE
// pays all the router holds, an amount the calldata does
// not state.
const routerHeldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
const ethFromRouter = (v4?.settledFromRouter ?? [])
.filter(
(s) =>
s.currency ===
"0x0000000000000000000000000000000000000000",
)
.map((s) => ({
currency: s.currency,
amount:
s.amount === CONTRACT_BALANCE ? null : s.amount,
}));
// What the user pays the step: what decodeV4Swap() says,
// and the ETH of each SETTLE from the router of a known
// amount when the router holds no other ETH.
const paidByUser = v4
? v4.paidByUser.concat(
routerHeldEth
? []
: ethFromRouter.filter((s) =>
present(s.amount),
),
)
: [];
if (v4) {
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
@@ -1733,10 +1896,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
commandNames[i] += " (could not be decoded)";
}
// The step's amount, if it sets the input side: what the
// user pays it of its input currency (see
// decodeV4Swap()), or, when the user pays none of it,
// what its swap actions spend of it, whoever pays that.
const paidIn = v4?.paidByUser.find(
// user pays it of its input currency, or, when the user
// pays none of it, what its swap actions spend of it,
// whoever pays that.
const paidIn = paidByUser.find(
(p) => p.currency === v4.tokenIn,
);
const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
@@ -1765,14 +1928,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
minOutput = v4.amountOutMin;
outputEstablished = true;
outputUnwrapped = false;
unwrapIntoRouterStep = null;
outputRecipients = takeRecipients;
outputStep = i;
} else if (v4) {
// A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, amountIn);
setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients);
setOutput(
i,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
);
}
// Every V4 step, with a swap action or without, takes
// from the sender what the user pays it (an amount an
@@ -1783,10 +1953,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined;
if (v4) {
for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
for (const p of paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
}
// A SETTLE of ETH from the router that `paidByUser` leaves
// out is a take of its own, even on the step that sets
// the input side. Either this decoder does not know its
// amount, or ETH an earlier step left in the router may
// pay part of that amount (see userPaid).
for (const p of ethFromRouter) {
if (!present(p.amount)) {
addUserPaid(p.currency, null);
} else if (routerHeldEth) {
userPaid.push({
token: p.currency,
amount: p.amount,
atMost: true,
});
}
}
if (!v4 || v4.settleUnread) {
@@ -1799,12 +1983,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// among it when the step that set the output side paid it
// to the router and the router still holds WETH. WETH
// that step paid to the user, or to anyone else, stays
// WETH, so when that step also paid some of its output
// elsewhere, or made a payout this decoder did not read,
// only part of it may be unwrapped. This decoder does not
// count how much of that step's minimum then reaches the
// router, nor read the UNWRAP_WETH's own minimum, so the
// output side keeps no figure.
// WETH. When that step also paid some of its output
// elsewhere or made a payout this decoder did not read,
// or this decoder treats a step between the two as one
// that may take WETH out of the router or pay anyone WETH
// (see lastWethStep), the WETH unwrapped may be only part
// of the output side's, or none of it.
// This decoder does not count how much of that step's
// minimum then reaches the UNWRAP_WETH, nor read the
// UNWRAP_WETH's own minimum, so the output side keeps no
// figure.
const u = decodeUnwrapWeth(inputs[i]);
const toRouter = (r) => present(r) && isRouter(r);
if (
present(outputToken) &&
@@ -1813,11 +2002,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputRecipients.some(toRouter)
) {
outputUnwrapped = true;
if (!outputRecipients.every(toRouter)) {
if (
unwrapIntoRouterStep === null &&
(u === null || isRouter(u.recipient))
) {
unwrapIntoRouterStep = i;
}
if (
!outputRecipients.every(toRouter) ||
lastWethStep > outputStep
) {
minOutput = null;
}
}
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
// It pays out all the router's WETH, as ETH. Into the
@@ -1839,8 +2036,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
// Any other step may pay an address this decoder does not
// read: PERMIT2_PERMIT_BATCH and the NFT commands among
// them, and a type the release names no command for.
// read: the NFT commands among them, and a type the
// release names no command for.
payeeUnread = true;
stepsUnread.push(i);
}
@@ -1854,6 +2051,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
if (!keepsRouterWeth) {
lastWethStep = i;
}
}
// After an UNWRAP_WETH that may have paid the output side's WETH to
// the router as ETH, a later step may pay that ETH out as WETH, as a
// WRAP_ETH of it to the user does. This decoder treats every later
// step but those lastWethStep names as one that may, and does not
// count how much of that ETH each pays out: after one, the output
// side reads as WETH with no figure. Neither this nor the loss of the
// figure in the UNWRAP_WETH branch is undone by any later step, a
// later UNWRAP_WETH included, but one that sets the output side again.
if (
outputUnwrapped &&
unwrapIntoRouterStep !== null &&
lastWethStep > unwrapIntoRouterStep
) {
outputUnwrapped = false;
minOutput = null;
}
// The Amount line, and the lines for what the steps take beyond it.
@@ -1861,23 +2078,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The total is what the steps take of Token In: the amount of the
// step that set the input side, with each later take of that token
// added to it while both are quantities. A take that cannot be added,
// being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure.
// being of another token, coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
// `atMost` set, gets lines of its own. One whose amount is not known
// also leaves the total unknown, with no figure.
//
// The last PERMIT2_PERMIT read names Token In. Its amount stands as
// the Amount figure in place of the total only in the plain shape of
// Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
// plain shape of Uniswap's own routes: it comes before the step that
// set the input side, it is the only PERMIT2_PERMIT in the
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is
// the router, the router may not let it fail, no step before it took
// its token or one not known, and this decoder read every step after
// it in full (see stepsUnread). Permit2 then lets the router take no
// more of the token than that amount, so the permit covers every take
// of it. A later step this decoder did not read may raise what Permit2
// lets the router take, as a PERMIT2_PERMIT_BATCH can. A step that
// may take from the sender what this decoder did not read leaves no
// figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// step leaves both sides unknown (below).
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token
// included), and this decoder read every step after it in full (see
// stepsUnread). Permit2 then lets the router take no more of the token
// than that amount, so the permit covers every take of it. A step
// before it takes under whatever allowance the router already had, so
// it may take more. A later permit or batch, or a later step this
// decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A permit
// on the zero address never stands as the Amount figure, because
// Permit2 has no allowance of ETH. A step that may take from the
// sender what this decoder did not read leaves no figure, permit or
// not (see spentUnread()), and so do a permit or batch whose input it
// cannot read (see permitUnread()) and an allowance of Token In to
// another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step
// leaves both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,
(b) => commands[b & commandTypeMask],
@@ -1885,7 +2112,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const permitStands =
permit !== null &&
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) &&
permit.token !== "0x0000000000000000000000000000000000000000" &&
!permit.allowedToFail &&
permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step);
@@ -1894,36 +2123,46 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// (see spentUnread()), or a token it does not know.
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of the step that set the input side
// counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
// whoever pays it. Stating none, it counts only when the user pays it
// some of its input token: paid from the router, it takes nothing
// from the sender.
// some of its input token.
const hasFirstTake =
inputEstablished && (present(inputAmount) || inputPaidByUser);
// The take of the step that set the input side, when the total
// starts from it and it stated no amount (see below), or null.
let firstTakeUnstated = null;
if (permit === null || sameAddress(inputToken, permit.token)) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
} else if (hasFirstTake) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so the total starts with no figure.
// permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount });
}
if (permit !== null) inputToken = permit.token;
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken);
if (ofTokenIn && permitStands) continue;
if (
ofTokenIn &&
typeof total === "bigint" &&
typeof t.amount === "bigint"
typeof t.amount === "bigint" &&
!t.atMost
) {
total += t.amount;
inTotal.push(t);
continue;
}
spentFromSender.push(t);
@@ -1947,10 +2186,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step, and a permit among
// them would name Token In. What the other steps state is
// then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
// or pay the user, before or after any other step. What the other
// steps state is then not the whole swap, so both sides are set
// unknown, with no figure, wherever the step stands.
if (hasSubPlan) {
inputToken = null;
inputAmount = null;
@@ -2024,8 +2262,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be
// compared with one.
const spentText = (amount, info) => {
// compared with one. `atMost` marks an amount that is only the most a
// take may be (see userPaid).
const spentText = (amount, info, atMost) => {
if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
}
@@ -2038,7 +2277,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" };
}
if (hasV2OrV3ExactOut) {
if (hasV2OrV3ExactOut || atMost) {
// A V2 or V3 exact-out swap spends at most this figure,
// whichever steps gave it (an amountInMax, the WRAP_ETH of a
// swap paid in ETH, a permit, or several steps' total), so it
@@ -2053,12 +2292,32 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
return amountText(amount, info);
};
// Each allowance to a spender other than the router. Code that a step
// runs, such as a V4 pool's hook or a token's own code, can call that
// spender, which can then take up to the allowance from the sender
// before the transaction ends. A permit or batch this decoder could
// not read may be to any spender, of any token.
const toAnotherSpender = permits.filter(
(p) => !sameAddress(p.spender, toAddress),
);
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, leaves no Amount figure, which may
// then be too low.
// or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be
// too low. An allowance of the zero address is never one of Token In,
// because Permit2 has no allowance of ETH. With no Amount line, each
// take the total adds up gets lines of its own, first, so that every
// amount this decoder read is shown.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token))
spentFromSender.every((s) => present(s.token)) &&
toAnotherSpender.every(
(p) =>
p.token === "0x0000000000000000000000000000000000000000" ||
(present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken)),
)
) {
const amount = spentText(inputAmount, inInfo);
details.push({
@@ -2066,6 +2325,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
value: amount.display,
rawValue: amount.raw,
});
} else {
spentFromSender.unshift(...inTotal);
}
// What steps take from the sender beyond the Amount line, in the shape
@@ -2076,11 +2337,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
details.push({
label: "Moved from the sender",
value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources)).display
? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources));
details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push({
label: "Moved to",
value: "The swap step that spends it",
@@ -2091,12 +2355,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
value: amountText(m.amount, info).display,
});
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(tokenMovedLine("Token moved", info));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
@@ -2108,6 +2372,40 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
);
}
// What each allowance of a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH lets
// its spender take: its amount, its token and the spender, an address
// on a line of its own. Permit2's maximum is unbounded. Only a permit
// whose amount stands as the Amount figure (see permitStands) gets
// none: that line already gives its amount and Token In its token,
// and its spender is the router. Every other allowance, one after the
// step that set the input side included, gets these lines, so nothing
// it lets anyone take goes unstated. A permit or batch this decoder
// could not read gets one line saying so.
for (const p of permits) {
if (permitStands && p.step === permit.step) continue;
if (!present(p.token)) {
details.push({
label: "Permit2 allowance",
value: UNDECODED_CURRENCY,
});
continue;
}
const info = permit2TokenInfo(p.token, sources);
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, info).display,
});
details.push(tokenMovedLine("Allowance token", info));
details.push({
label: "Allowance spender",
value: p.spender,
address: p.spender,
});
}
// Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is
@@ -2143,9 +2441,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// step pays the bridge, when one moves the sender's tokens into the
// router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
// step pays the sender, there is no such line, and no allowance goes
// to another spender (see toAnotherSpender). When no step names anyone
// paid, one line says so. `paidTo` holds these lines and the token
// line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -2157,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
const info = permit2TokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
@@ -2165,9 +2464,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
paidTo.push(tokenMovedLine("Token moved into the router", info));
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2184,15 +2481,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
getAddress(token),
sources,
tokenInfo(getAddress(token), sources),
),
);
}
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
const received = paidToSender && paidTo.length === 0;
const received =
paidToSender &&
paidTo.length === 0 &&
toAnotherSpender.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
+4 -4
View File
@@ -1,9 +1,9 @@
// The USD total of an address that holds something this build cannot price
// (issue #261).
//
// Prices exist for the top 25 tokens only, so an address can hold real assets
// with no price attached. Summing what is priced and printing the result as
// the total says "$0.00" for an address holding nothing but unpriced tokens —
// Only a token with a price feed has a price, so an address can hold real
// assets with no price attached. Summing what is priced and printing the result
// as the total says "$0.00" for an address holding nothing but unpriced tokens —
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
// direction that matters. The two are separate facts here, the same way an
// absent holders_count is not a count of zero.
@@ -63,7 +63,7 @@ const PARTLY_PRICED = {
beforeEach(() => {
clearPrices();
prices.ETH = 2000;
prices.USDC = 1;
prices[USDC] = 1;
state.wallets = [];
state.trackedTokens = [];
state.showZeroBalanceTokens = false;
+2
View File
@@ -41,6 +41,8 @@ jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now());
}),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
getProvider: jest.fn(() => ({})),
}));
+87
View File
@@ -0,0 +1,87 @@
// "Allow weak passwords" in Settings is off until the user ticks it. While it
// is off, AddWallet refuses a password shorter than 12 characters. While it is
// on, AddWallet accepts any password that is not empty
// (https://git.eeqj.de/sneak/AutistMask/issues/674). These boot the real popup,
// set the checkbox in Settings as the user does, and then add a wallet.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(async () => ({
salt: "salt",
nonce: "nonce",
ciphertext: "ciphertext",
})),
}));
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const CHECKBOX = "settings-allow-weak-passwords";
const ERROR = "add-wallet-password-error";
afterEach(() => {
cleanupPopup();
});
// Boots a popup that holds one wallet and opens Settings, where the checkbox
// starts unticked. Ticks it when `allow` is true. Then opens AddWallet and
// presses the die for a recovery phrase.
async function openAddWallet(allow) {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checkbox = page.node(CHECKBOX);
expect(checkbox.checked).toBe(false);
if (allow) {
checkbox.checked = true;
for (const fn of checkbox.listeners.change) await fn();
expect(page.storage.read("autistmask").allowWeakPasswords).toBe(true);
}
await page.click("btn-main-add-wallet");
await page.click("btn-generate-phrase");
return page;
}
// Types `password` into both password fields and presses Import.
async function importWith(page, password) {
page.node("add-wallet-password").value = password;
page.node("add-wallet-password-confirm").value = password;
await page.click("btn-add-wallet-confirm");
}
function storedWalletCount(page) {
return page.storage.read("autistmask").wallets.length;
}
describe("Allow weak passwords", () => {
test("unticked: a password shorter than 12 characters is refused", async () => {
const page = await openAddWallet(false);
await importWith(page, "short");
expect(page.text(ERROR)).toBe(
"Password must be at least 12 characters.",
);
expect(storedWalletCount(page)).toBe(1);
expect(page.visibleViews()).toEqual(["add-wallet"]);
});
test("ticked: a password shorter than 12 characters is accepted", async () => {
const page = await openAddWallet(true);
await importWith(page, "short");
expect(page.text(ERROR)).toBe("");
expect(storedWalletCount(page)).toBe(2);
expect(page.visibleViews()).toEqual(["main"]);
});
test.each([false, true])(
"an empty password is refused (ticked: %s)",
async (allow) => {
const page = await openAddWallet(allow);
await importWith(page, "");
expect(page.text(ERROR)).toBe("Please choose a password.");
expect(storedWalletCount(page)).toBe(1);
expect(page.visibleViews()).toEqual(["add-wallet"]);
},
);
});
+3
View File
@@ -84,6 +84,9 @@ function walletsHolding(token, decimals) {
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
// The approval screen reads the explorer's report only while "Use
// Blockscout" is on.
state.useBlockscout = true;
});
describe("resolveTokenDecimals", () => {
+308
View File
@@ -0,0 +1,308 @@
// While "Use Blockscout" is off, the approval screens, and the screens that
// follow TxApproval, take a token's name and scale from the bundled list and
// the tracked tokens alone (https://git.eeqj.de/sneak/AutistMask/issues/668).
// A token missing from both gets a line under it. The line says Blockscout's
// report of the user's balances is not used to name the token. While the
// setting is on, Blockscout's report of the user's holding is a source too, as
// before.
//
// Booted through the real popup entry point, as the approval window or as a
// popup reopened onto SuccessTx, WaitTx or ErrorTx. The vault is mocked so that
// no password has to be hashed: the stored secret decrypts to the test key
// below.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const { Interface, Wallet } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// A published test key, never used for real funds.
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const ORIGIN = "https://dapp.example";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
const TX_HASH = "0x" + "ab".repeat(32);
// Outside the bundled list, and not tracked.
const TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const LINE =
'Blockscout\'s report of your balances is not used to name this token, because "Use Blockscout" is off in Settings.';
// TOKEN as Blockscout reports the user's holding of it.
const TOKEN_ROW = {
address: TOKEN,
name: "Novel",
symbol: "NOVEL",
decimals: "6",
balance: "5000.0",
holders: 5000,
};
// A transfer of 5,000 of a 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 5000000000n]);
// An EIP-2612 permit letting RECIPIENT take 5,000 of TOKEN.
const PERMIT = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: { name: "Novel", chainId: 1, verifyingContract: TOKEN },
message: {
owner: signer.address,
spender: RECIPIENT,
value: "5000000000",
nonce: "0",
deadline: "1790000000",
},
};
// The stored profile: the user's one address holds `tokenBalances`.
function profile(useBlockscout, tokenBalances, extra) {
return unversionedValidProfile({
useBlockscout,
wallets: [
{
type: "key",
name: "Wallet 1",
encryptedSecret: "encrypted-key",
addresses: [
{
address: signer.address,
balance: "1.0",
tokenBalances,
},
],
},
],
activeAddress: signer.address,
allowedSites: { [signer.address]: [ORIGIN] },
...extra,
});
}
// The approval window for the site's request. The request is a transfer sent to
// `token`, or a signature request when `signParams` is given. The background
// answers the signed transaction with `txResponse`.
function bootApproval(stored, { token = TOKEN, signParams, txResponse } = {}) {
return bootPopup(stored, {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
reply({
type: signParams ? "sign" : "tx",
origin: ORIGIN,
isPhishingDomain: false,
approvedFrom: signer.address,
signParams,
approvedTx: {
type: 2,
from: signer.address,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: token,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
} else if (msg.type === "AUTISTMASK_TX_RESPONSE") {
reply(txResponse);
} else {
reply(undefined);
}
},
},
});
}
beforeEach(() => {
// The page logs its failed RPC reads; the stub node in popupBoot.js
// answers none.
jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
cleanupPopup();
jest.restoreAllMocks();
});
describe("TxApproval", () => {
test("has the line under a token it cannot name while Use Blockscout is off", async () => {
const page = await bootApproval(profile(false, []));
const details = page.node("approve-tx-decoded-details").innerHTML;
expect(details).toContain("Unknown token");
expect(details).toContain(LINE);
expect(details).toContain("5000000000 base units (decimals unknown)");
cleanupPopup();
// The bundled list names USDC, so there is nothing to say.
const bundled = await bootApproval(profile(false, []), {
token: USDC,
});
expect(
bundled.node("approve-tx-decoded-details").innerHTML,
).not.toContain(LINE);
cleanupPopup();
const on = await bootApproval(profile(true, [TOKEN_ROW]));
const named = on.node("approve-tx-decoded-details").innerHTML;
expect(named).toContain("5000.0000 NOVEL");
expect(named).not.toContain(LINE);
});
test("names nothing from a Blockscout entry still in the balances while Use Blockscout is off", async () => {
const page = await bootApproval(profile(false, [TOKEN_ROW]));
const details = page.node("approve-tx-decoded-details").innerHTML;
expect(details).not.toContain("NOVEL");
expect(details).toContain("Unknown token");
expect(details).toContain(LINE);
});
test.each([
["WaitTx", { txHash: TX_HASH }, "wait-tx"],
[
"ErrorTx",
{ error: "The transaction could not be sent.", retryable: false },
"error-tx",
],
])(
"%s after it says so under the amount while Use Blockscout is off",
async (_name, txResponse, view) => {
const page = await bootApproval(profile(false, []), {
txResponse,
});
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockResolvedValue(SIGNER_KEY);
page.node("approve-tx-password").value = "any password";
await page.click("btn-approve-tx");
expect(page.visibleViews()).toEqual([view]);
expect(page.node(view + "-token-note").innerHTML).toBe(
`<div class="text-xs text-muted">${LINE}</div>`,
);
},
);
});
test("SuccessTx says so under the decoded token while Use Blockscout is off", async () => {
const page = await bootPopup(
profile(false, [], {
currentView: "success-tx",
viewStack: ["main"],
viewData: {
amount: "5000000000 base units (decimals unknown)",
symbol: "?",
to: RECIPIENT,
hash: TX_HASH,
blockNumber: 21000000,
chainId: "0x1",
decoded: {
name: "Token Transfer",
description: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: TOKEN,
address: TOKEN,
isToken: true,
},
],
},
},
}),
);
expect(page.visibleViews()).toEqual(["success-tx"]);
const decoded = page.node("success-tx-decoded").innerHTML;
expect(decoded).toContain("Unknown token");
expect(decoded).toContain(LINE);
});
test.each([
[
"wait-tx",
{
pendingWait: {
hash: TX_HASH,
txInfo: {
to: RECIPIENT,
amount: "1",
token: 42,
chainId: "0x1",
},
broadcastTime: Date.now(),
},
},
],
[
"error-tx",
{
amount: "1",
symbol: "?",
token: 42,
to: RECIPIENT,
hash: TX_HASH,
message: "The transaction failed.",
chainId: "0x1",
},
],
])(
"a popup reopened onto %s shows no line for a stored token that is not text",
async (view, viewData) => {
const page = await bootPopup(
profile(false, [], {
currentView: view,
viewStack: ["main"],
viewData,
}),
);
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual([view]);
expect(page.node(view + "-token-note").innerHTML).toBe("");
},
);
test("SignApproval says so under a permit's token while Use Blockscout is off", async () => {
const signParams = {
method: "eth_signTypedData_v4",
typedData: JSON.stringify(PERMIT),
from: signer.address,
};
const page = await bootApproval(profile(false, []), { signParams });
const message = page.node("approve-sign-message").innerHTML;
expect(message).toContain("Unknown token");
expect(message).toContain(LINE);
cleanupPopup();
const on = await bootApproval(profile(true, [TOKEN_ROW]), { signParams });
const named = on.node("approve-sign-message").innerHTML;
expect(named).toContain("5000.0000 NOVEL");
expect(named).not.toContain(LINE);
});
+134
View File
@@ -12,6 +12,7 @@ const {
cleanupPopup,
settle,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
afterEach(cleanupPopup);
@@ -174,3 +175,136 @@ test("a connection prompt in the toolbar popup leaves the browser window alone",
expect(windows.updates).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;
const created = [];
const removed = [];
// Every URL the background put on the browser action. A site approval
// raised through action.openPopup() opens no window at all, so this is
// the only place its id appears.
const actionPopups = [];
// Every URL the background put on the browser action. When a test gives
// the URL the action holds before the worker starts, that URL comes
// first. A site approval raised through action.openPopup() opens no
// window at all, so this is the only place its id appears.
const actionPopups = opts.toolbarPopup ? [opts.toolbarPopup] : [];
const openPopup = jest.fn(() => Promise.resolve());
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
// 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
// window left to retry in, so leaving it standing answers the requesting
// 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 answered, and frees the next request", async () => {
// leaves the approval standing for a retry. The toolbar popup opens it, so
// the user can still retry it, and answering it frees the slot.
test("an approval whose window closed under a failed attempt is retried from the toolbar popup, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground();
@@ -808,11 +808,28 @@ describe("one transaction approval at a time", () => {
stalled.resolve();
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(first.result()).toBeNull();
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html?approval=" + first.id(),
);
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();
await settle();
expect(second.result()).toBeNull();
@@ -853,8 +870,10 @@ describe("one transaction approval at a time", () => {
const first = bg.requestTx();
await settle();
// The user closes the approval window, which rejects it.
bg.closeWindow(1);
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id: first.id(), approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
@@ -897,7 +916,7 @@ describe("the worker keeps running while an approval is pending", () => {
try {
const bg = loadBackground();
const calls = global.chrome.runtime.getPlatformInfo;
bg.requestTx();
const pending = bg.requestTx();
await settle();
expect(startTimer).toHaveBeenCalledTimes(1);
expect(startTimer.mock.calls[0][1]).toBe(20000);
@@ -906,11 +925,23 @@ describe("the worker keeps running while an approval is pending", () => {
tick();
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);
await settle();
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);
} finally {
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
// against everything else that retires an approval, not just against a second
// 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.
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 () => {
const bg = loadBackground();
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
// a tx or sign approval. The popup connects its port in show() before it
// knows the approval's type, and the background sets portConnected without
// looking at the type either, so a tx approval in the fallback window
// carries the flag too. Without the conjunct the window event would skip
// it, windowClosed would never be set, releaseApproval() would never settle
// 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 () => {
// That skip must not reach a tx or sign approval. The popup connects its
// port in show() before it knows the approval's type, and the background
// sets portConnected without looking at the type either, so a tx approval
// carries the flag too. If the window event skipped it, windowClosed would
// never be set, and the toolbar popup would never open the approval.
test("a tx window closed with the port connected is still opened by the toolbar popup", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
@@ -2600,9 +2845,10 @@ describe("a site connection decided as the popup closes", () => {
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(pending.result()).toBeNull();
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].top).toBeUndefined();
// The request waits on the second window rather than failing:
// closing that window is refusing the prompt.
// The request waits on the second window rather than failing. Once
// that window closes, the toolbar popup opens the request.
expect(sign.result()).toBeNull();
bg.closeWindow(2);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(sign.result()).toBeNull();
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html?approval=" + sign.id(),
);
});
});
+102 -1
View File
@@ -19,7 +19,10 @@
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// A network switch landing inside a balance refresh's round trip is covered at
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
//
// All use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.
@@ -139,6 +142,8 @@ function loadWorker(networkId, opts) {
},
refreshBalances:
options.refreshBalances || jest.fn(async () => undefined),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
@@ -395,3 +400,99 @@ describe("a balance refresh under another handler's state read", () => {
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
});
});
describe("a balance refresh under a network switch", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
// address's balances, and a refresh that started on the previous network
// must not write that network's back over them when it returns. The same
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// The user switches to Ethereum Mainnet in Settings while the round
// trip is out.
const during = bg.persisted();
applyChainSwitchFields(during, MAINNET.id);
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
expect(ranOn).toEqual(["sepolia", "mainnet"]);
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});
describe("a balance refresh under a change of Use Blockscout", () => {
// Turning "Use Blockscout" off drops the tokens the user has not tracked
// (src/popup/views/settings.js). Blockscout may have answered a refresh
// just before. That refresh still saves its balances when it returns, but
// not those tokens. The same refresh in the popup is in
// tests/blockscoutEtherscanSettings.test.js.
test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => {
const roundTrip = deferred();
const started = deferred();
const bg = loadWorker("mainnet", {
refreshBalances: async (wallets) => {
started.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: 50000,
},
];
},
});
global.chrome.storage.write("autistmask", {
...bg.persisted(),
useBlockscout: true,
});
const refresh = bg.fireBalanceAlarm();
await started.promise;
// The user unticks "Use Blockscout" in Settings while the round trip
// is out.
global.chrome.storage.write("autistmask", {
...bg.persisted(),
useBlockscout: false,
});
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.wallets[0].addresses[0].balance).toBe("7.0");
expect(after.wallets[0].addresses[0].tokenBalances).toEqual([]);
});
});
+16 -4
View File
@@ -18,6 +18,17 @@ const {
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const ADDRESS = "0x1111111111111111111111111111111111111111";
const TRACKED = {
address: "0x7777777777777777777777777777777777777777",
symbol: "TRK",
decimals: 6,
};
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
globalThis.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const realFetch = globalThis.fetch;
let logged;
@@ -30,9 +41,9 @@ beforeEach(() => {
const failedToFetch = async () => {
throw new TypeError("Failed to fetch");
};
// The RPC calls (ETH balance, ENS name, and the scan's balance and
// transaction count) and the explorer request (token balances) all fail
// the same way.
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and
// the scan's balance and transaction count) and the explorer request (the
// other token balances) all fail the same way.
FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch);
});
@@ -49,7 +60,7 @@ function refresh(signal) {
wallets,
RPC_URL,
EXPLORER_URL,
[],
[TRACKED],
"mainnet",
signal,
);
@@ -60,6 +71,7 @@ test("a failure while the popup is open is reported", async () => {
for (const label of [
"ETH balance failed",
"ENS reverse failed",
"token balance failed",
"fetchTokenBalances failed: Failed to fetch",
]) {
expect(logged.some((line) => line.includes(label))).toBe(true);
+212
View File
@@ -0,0 +1,212 @@
// A balance refresh the popup started on one network, and that returns after
// the network was switched to another, writes nothing: the switch cleared every
// address's balances, and the previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
// by the user in this popup's Settings, or by the background after the user
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
//
// The last test covers a balance refresh by the background that is saved while
// a switch in Settings waits for the lock
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
afterEach(() => {
cleanupPopup();
});
// Starts the ten-second refresh and holds it open. The balance stand-in writes
// Ethereum Mainnet's balances in place, on the records handed in, as the real
// refreshBalances() does, once the returned function is called. Resolves with
// that function and the refresh still running.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
});
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
// recorded as done.
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The switch is made only once the refresh's save, holding the lock, has
// written Ethereum Mainnet's balances, and the switch then clears them.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
// The next read of the stored record, the refresh's save's, is held.
const get = page.storage.local.get;
const read = get.getMockImplementation();
let releaseRead;
get.mockImplementationOnce(async (key) => {
await new Promise((resolve) => {
releaseRead = resolve;
});
return read(key);
});
const finishing = refresh.finish();
await page.settle();
// The user picks Sepolia meanwhile; the switch's own save waits for the
// refresh's save to let the lock go.
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
releaseRead();
await finishing;
await switching;
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
test("a refresh that returns after the background switched the network writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { saveState } = require("../src/shared/state");
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told of it
// yet.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
await refresh.finish();
expectNothingStored(page);
// Nor does a later save by this page, such as every navigation makes.
await saveState();
expectNothingStored(page);
});
// 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);
});
+848
View File
@@ -0,0 +1,848 @@
// Blockscout and Etherscan are sent none of the user's data until the user
// turns on a setting for each
// (https://git.eeqj.de/sneak/AutistMask/issues/668). A new profile has "Use
// Blockscout" and "Check recipients on Etherscan" both off. While a setting is
// off, no request sends its service the user's data.
//
// One request still reaches Blockscout while "Use Blockscout" is off: the check
// behind "Save" beside the Blockscout URL in Settings. That check asks the URL
// only for `/stats` and sends none of the user's data.
//
// Before any other request to Blockscout or Etherscan, the wallet reads that
// service's setting from storage, not the page's copy. The wallet reads the
// setting again when the answer lands. So a change saved by another page
// applies at once. An open page also takes such a change and draws its screens
// again.
//
// Only Blockscout reports the tokens the user has not tracked. While "Use
// Blockscout" is off, no address keeps them and no screen lists them.
//
// The functions that make the requests are driven directly, against a fetch()
// that records every URL it is asked for. The screens are driven through the
// real popup over a stored profile (tests/support/popupBoot.js). The fetch()
// stub in popupBoot.js records the same way.
"use strict";
const { FetchRequest } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
DEFAULT_STATE,
normalizePersisted,
} = require("../src/shared/persistedState");
const {
fetchTokenBalances,
refreshBalances,
} = require("../src/shared/balances");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { getFullWarnings } = require("../src/shared/addressWarnings");
const { checkEtherscanLabel } = require("../src/shared/etherscanLabels");
const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers");
const RPC_URL = "https://rpc.example.invalid";
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
const TX_HASH = "0x" + "ab".repeat(32);
const ZERO_WORD = "0x" + "0".repeat(64);
// USDC, from the bundled list, as Blockscout reports a holding of it.
const USDC_ROW = {
value: "5000000",
token: {
type: "ERC-20",
address_hash: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
symbol: "USDC",
name: "USD Coin",
decimals: "6",
holders_count: "50000",
},
};
// A transfer of 1 ETH from RECIPIENT to ADDRESS, as Blockscout lists it in
// ADDRESS's history.
const HISTORY_ITEM = {
hash: TX_HASH,
block_number: 21000000,
timestamp: "2026-02-27T12:00:00.000Z",
from: { hash: RECIPIENT },
to: { hash: ADDRESS, is_contract: false },
value: "1000000000000000000",
method: null,
status: "ok",
};
// Etherscan's page for RECIPIENT, which labels it a phishing address.
const PHISHING_PAGE =
"<html><head><title>Fake_Phishing1 | Address: " +
RECIPIENT +
" | Etherscan</title></head><body></body></html>";
const realFetch = globalThis.fetch;
afterEach(() => {
cleanupPopup();
jest.restoreAllMocks();
});
describe("a new profile", () => {
test("has both settings off", () => {
expect(DEFAULT_STATE.useBlockscout).toBe(false);
expect(DEFAULT_STATE.checkRecipientsOnEtherscan).toBe(false);
const loaded = normalizePersisted(undefined);
expect(loaded.useBlockscout).toBe(false);
expect(loaded.checkRecipientsOnEtherscan).toBe(false);
});
test("keeps either setting the user turned on", () => {
const loaded = normalizePersisted({
useBlockscout: true,
checkRecipientsOnEtherscan: true,
});
expect(loaded.useBlockscout).toBe(true);
expect(loaded.checkRecipientsOnEtherscan).toBe(true);
});
});
describe("where each request is made", () => {
// Every URL that fetch() was asked for, in order.
let asked;
// What Blockscout and Etherscan answer: USDC among ADDRESS's tokens,
// HISTORY_ITEM in its history, and PHISHING_PAGE for RECIPIENT.
function answer(url) {
return {
ok: true,
status: 200,
json: async () => {
if (url.endsWith("/token-balances")) return [USDC_ROW];
if (url.endsWith("/transactions")) {
return { items: [HISTORY_ITEM] };
}
return { items: [] };
},
text: async () => PHISHING_PAGE,
};
}
async function recordAndAnswer(url) {
asked.push(String(url));
return answer(String(url));
}
beforeEach(() => {
asked = [];
globalThis.fetch = jest.fn(recordAndAnswer);
});
afterEach(() => {
globalThis.fetch = realFetch;
delete globalThis.chrome;
});
// Storage holds `profile` as the stored profile, or nothing when it is
// undefined.
function storing(profile) {
globalThis.chrome = {
storage: makeStorageStub(
profile === undefined ? {} : { autistmask: profile },
),
};
}
test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => {
storing({ useBlockscout: false });
expect(await fetchTokenBalances(ADDRESS, BLOCKSCOUT, [])).toEqual([]);
expect(asked).toEqual([]);
storing({ useBlockscout: true });
const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, []);
expect(rows.map((row) => row.symbol)).toEqual(["USDC"]);
expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
]);
});
test("the transaction history is asked for only while Use Blockscout is on", async () => {
storing({ useBlockscout: false });
expect(
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1"),
).toEqual([]);
expect(asked).toEqual([]);
storing({ useBlockscout: true });
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1");
expect(asked.sort()).toEqual([
BLOCKSCOUT +
"/addresses/" +
ADDRESS +
"/token-transfers?type=ERC-20",
BLOCKSCOUT + "/addresses/" + ADDRESS + "/transactions",
]);
});
test("the Etherscan label check is made only while the stored profile has Check recipients on Etherscan on", async () => {
const provider = {
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
const checkWith = async (profile) => {
storing(profile);
await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" });
};
await checkWith(undefined);
await checkWith({ checkRecipientsOnEtherscan: false });
// Only a stored true is on, as when the profile is loaded.
await checkWith({ checkRecipientsOnEtherscan: "true" });
await checkWith({ checkRecipientsOnEtherscan: 42 });
expect(asked).toEqual([]);
await checkWith({ checkRecipientsOnEtherscan: true });
expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]);
});
// Asks with `setting` on in storage. Once every request is out, storage
// holds `after` for the setting, as when the user changes it here or on
// another page. Only then does each request get its answer.
async function answeredAfter(setting, after, ask) {
storing({ [setting]: true });
let release;
const held = new Promise((resolve) => {
release = resolve;
});
let requested;
const out = new Promise((resolve) => {
requested = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
requested();
await held;
return recordAndAnswer(url);
});
const result = ask();
await out;
globalThis.chrome.storage.write("autistmask", { [setting]: after });
release();
return result;
}
// One row per request, giving:
//
// - its name
// - the setting that gates it
// - how to make it
// - its answer while that setting is off
test.each([
[
"Blockscout's token balances",
"useBlockscout",
() => fetchTokenBalances(ADDRESS, BLOCKSCOUT, []),
[],
],
[
"the transaction history",
"useBlockscout",
() => fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1"),
[],
],
[
"the Etherscan label check",
"checkRecipientsOnEtherscan",
() => checkEtherscanLabel(RECIPIENT, "0x1"),
null,
],
])(
"%s keeps an answer that lands while its setting is on, and drops one that lands after it went off",
async (_what, setting, ask, dropped) => {
expect(await answeredAfter(setting, true, ask)).not.toEqual(
dropped,
);
expect(await answeredAfter(setting, false, ask)).toEqual(dropped);
},
);
describe("a balance refresh", () => {
// The RPC endpoint answers every balance with zero. The ENS reverse
// lookup makes the only other calls, and it finds no resolver.
beforeEach(() => {
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const reply = (r) => ({
jsonrpc: "2.0",
id: r.id,
result: r.method === "eth_getBalance" ? "0x0" : ZERO_WORD,
});
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());
});
// An address refreshed while "Use Blockscout" was on. It holds the USDC
// that Blockscout reported then.
function addressWithUsdc() {
return {
address: ADDRESS,
tokenBalances: [
{
address: USDC_ROW.token.address_hash,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
],
};
}
async function refresh(addr, useBlockscout) {
storing({ useBlockscout });
await refreshBalances(
[{ addresses: [addr] }],
RPC_URL,
BLOCKSCOUT,
[],
"mainnet",
);
}
test("asks Blockscout nothing while Use Blockscout is off, and then lists no token the user has not tracked", async () => {
const addr = addressWithUsdc();
await refresh(addr, false);
expect(asked).toEqual([]);
expect(addr.tokenBalances).toEqual([]);
});
test("asks Blockscout for the token balances while Use Blockscout is on", async () => {
const addr = addressWithUsdc();
await refresh(addr, true);
expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
]);
expect(addr.tokenBalances.map((row) => row.symbol)).toEqual([
"USDC",
]);
});
});
});
describe("the popup", () => {
// Every URL the booted page handed to fetch().
function fetched() {
return globalThis.fetch.mock.calls.map((call) => String(call[0]));
}
function blockscoutOrEtherscan(url) {
return url.includes("blockscout") || url.includes("etherscan");
}
beforeEach(() => {
// The screens below log their failed RPC reads; the stub node in
// popupBoot.js answers none.
jest.spyOn(console, "error").mockImplementation(() => {});
});
test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.node("home-tx-list").innerHTML).toContain(
escapeHtml(HISTORY_OFF_TEXT),
);
expect(page.hidden("home-tokens-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
expect(on.node("home-tx-list").innerHTML).toContain(
"No transactions found.",
);
expect(on.hidden("home-tokens-note")).toBe(true);
});
// The transaction screen, as a reopened popup draws it.
function onTransactionScreen(extra) {
return unversionedValidProfile({
currentView: "transaction",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
viewData: {
tx: {
hash: TX_HASH,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId: "0x1",
},
},
...extra,
});
}
test("TransactionDetail asks Blockscout for the transaction only while Use Blockscout is on", async () => {
const page = await bootPopup(onTransactionScreen());
expect(page.visibleViews()).toEqual(["transaction"]);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(
onTransactionScreen({ useBlockscout: true }),
);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
BLOCKSCOUT + "/transactions/" + TX_HASH,
]);
expect(on.hidden("tx-detail-blockscout-note")).toBe(true);
});
// Another page turns "Use Blockscout" off while TransactionDetail's request
// to Blockscout is out. This page draws TransactionDetail again with its
// line, and only then does Blockscout answer.
test("TransactionDetail shows nothing from an answer that lands after another page turned Use Blockscout off", async () => {
const page = await bootPopup(
onTransactionScreen({ useBlockscout: true }),
);
let release;
const held = new Promise((resolve) => {
release = resolve;
});
globalThis.fetch = jest.fn(async () => {
await held;
return {
ok: true,
status: 200,
json: async () => ({
block_number: 21000000,
fee: { value: "21000000000000" },
raw_input: "0x12345678",
method: "doSomething",
}),
};
});
require("../src/popup/views/transactionDetail").render();
await page.settle();
expect(fetched()).toEqual([BLOCKSCOUT + "/transactions/" + TX_HASH]);
await anotherPageAnnounces(page, { useBlockscout: false });
release();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
for (const id of [
"tx-detail-block-section",
"tx-detail-network-section",
"tx-detail-calldata-section",
"tx-detail-rawdata-section",
]) {
expect(page.hidden(id)).toBe(true);
}
expect(fetched()).toHaveLength(1);
});
// The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a
// reopened popup draws it.
function onConfirmScreen(extra) {
return unversionedValidProfile({
currentView: "confirm-tx",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
viewData: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: RECIPIENT,
amount: "0.1",
chainId: "0x1",
},
},
...extra,
});
}
test("ConfirmTx asks Etherscan about the recipient only while Check recipients on Etherscan is on", async () => {
const page = await bootPopup(onConfirmScreen());
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
expect(page.hidden("confirm-etherscan-note")).toBe(false);
expect(page.hidden("confirm-etherscan-warning")).toBe(true);
cleanupPopup();
const on = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
"https://etherscan.io/address/" + RECIPIENT,
]);
expect(on.hidden("confirm-etherscan-note")).toBe(true);
expect(on.hidden("confirm-etherscan-warning")).toBe(false);
});
// Ticks or unticks a checkbox on the page, as the user does.
async function setBox(page, id, checked) {
const box = page.node(id);
box.checked = checked;
for (const fn of box.listeners.change) await fn();
await page.settle();
}
test("Welcome offers both settings unticked, and ticking one there stores it", async () => {
const page = await bootPopup(undefined);
expect(page.visibleViews()).toEqual(["welcome"]);
expect(page.node("welcome-use-blockscout").checked).toBe(false);
expect(page.node("welcome-check-recipients-on-etherscan").checked).toBe(
false,
);
await setBox(page, "welcome-use-blockscout", true);
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await setBox(page, "welcome-check-recipients-on-etherscan", true);
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true,
);
});
test("Settings shows both settings unticked, and ticking one there stores it", async () => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
expect(page.node("settings-use-blockscout").checked).toBe(false);
expect(
page.node("settings-check-recipients-on-etherscan").checked,
).toBe(false);
await setBox(page, "settings-use-blockscout", true);
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await setBox(page, "settings-check-recipients-on-etherscan", true);
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true,
);
});
// A token the user tracks, and an untracked one that Blockscout reported.
// Both are written in lower case, because the balance rows' and the
// dropdown's token ids are compared in lower case below.
const TRACKED = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const UNTRACKED = TOKEN_ADDRESS.toLowerCase();
const SECOND_ADDRESS = "0x3333333333333333333333333333333333333333";
function tokenRow(address, symbol) {
return {
address,
name: symbol,
symbol,
decimals: 18,
balance: "5.0",
holders: 50000,
};
}
// AddressDetail of the first of two addresses, as a reopened popup draws
// it. Both addresses hold the tracked token. While "Use Blockscout" is on,
// both hold the untracked one too.
function onAddressDetail(useBlockscout) {
const profile = unversionedValidProfile({
useBlockscout,
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [
{ address: TRACKED, name: "TRK", symbol: "TRK", decimals: 18 },
],
});
const wallet = profile.wallets[0];
wallet.nextIndex = 2;
wallet.addresses.push({ address: SECOND_ADDRESS, balance: "0.0" });
for (const addr of wallet.addresses) {
addr.tokenBalances = useBlockscout
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
}
return profile;
}
// The token ids of the options created after the first `from` elements.
// The page stub keeps no children, so this reads the options for Send's
// dropdown from the elements the page created.
function optionsSince(page, from) {
return page.document.created
.slice(from)
.filter((el) => el.tagName === "OPTION")
.map((el) => el.value.toLowerCase());
}
test("unticking Use Blockscout from AddressDetail's gear takes the untracked token off AddressDetail, Send's dropdown and Home on Back", async () => {
const page = await bootPopup(onAddressDetail(true));
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain(UNTRACKED);
await page.click("btn-settings");
await setBox(page, "settings-use-blockscout", false);
const from = page.document.created.length;
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain(TRACKED);
expect(balances).not.toContain(UNTRACKED);
expect(page.hidden("address-tokens-note")).toBe(false);
expect(optionsSince(page, from)).toEqual([TRACKED]);
await page.click("btn-send");
expect(page.visibleViews()).toEqual(["send"]);
expect(page.hidden("send-token-dropdown")).toBe(false);
expect(page.hidden("send-tokens-note")).toBe(false);
await page.click("btn-send-back");
await page.click("btn-address-back");
expect(page.visibleViews()).toEqual(["main"]);
const home = page.node("wallet-list").innerHTML;
expect(home).toContain(TRACKED);
expect(home).not.toContain(UNTRACKED);
const stored = page.storage.read("autistmask");
for (const addr of stored.wallets[0].addresses) {
expect(
addr.tokenBalances.map((row) => row.address.toLowerCase()),
).toEqual([TRACKED]);
}
});
// Send's dropdown was filled under the old setting, and Back cannot draw
// Send again. So Back lands on the screen under it.
test.each([
["unticking", true],
["ticking", false],
])(
"%s Use Blockscout from Send's gear lands Back on AddressDetail",
async (_how, before) => {
const page = await bootPopup(onAddressDetail(before));
await page.click("btn-send");
expect(page.visibleViews()).toEqual(["send"]);
await page.click("btn-settings");
await setBox(page, "settings-use-blockscout", !before);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.hidden("address-tokens-note")).toBe(!before);
},
);
test("Send says under its token dropdown that only tracked tokens are listed while Use Blockscout is off", async () => {
const off = await bootPopup(onAddressDetail(false));
await off.click("btn-send");
expect(off.hidden("send-token-dropdown")).toBe(false);
expect(off.hidden("send-tokens-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(onAddressDetail(true));
await on.click("btn-send");
expect(on.hidden("send-token-dropdown")).toBe(false);
expect(on.hidden("send-tokens-note")).toBe(true);
});
test("Send opened from AddressToken shows neither the dropdown nor its line", async () => {
const page = await bootPopup({
...onAddressDetail(false),
currentView: "address-token",
viewStack: ["main", "address"],
selectedToken: "ETH",
});
expect(page.visibleViews()).toEqual(["address-token"]);
await page.click("btn-address-token-send");
expect(page.visibleViews()).toEqual(["send"]);
expect(page.hidden("send-token-dropdown")).toBe(true);
expect(page.hidden("send-token-static")).toBe(false);
});
test("DeleteAddress says only tracked tokens are listed while Use Blockscout is off", async () => {
for (const useBlockscout of [false, true]) {
const page = await bootPopup(onAddressDetail(useBlockscout));
require("../src/popup/views/deleteAddress").show(0, 1);
expect(page.visibleViews()).toEqual(["delete-address-confirm"]);
expect(page.hidden("delete-address-tokens-note")).toBe(
useBlockscout,
);
cleanupPopup();
}
});
// The token ids of the first address's balances in the stored profile.
function storedTokens(page) {
return page.storage
.read("autistmask")
.wallets[0].addresses[0].tokenBalances.map((row) =>
row.address.toLowerCase(),
);
}
// Turning the setting off dropped the untracked token. A refresh that
// started while the setting was on still saves its balances when it
// returns, but not that token. The same refresh in the background is in
// tests/backgroundStateIsolation.test.js.
test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => {
const page = await bootPopup(onAddressDetail(true));
const { refreshBalances: held } = require("../src/shared/balances");
let answer;
held.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
tokenRow(TRACKED, "TRK"),
tokenRow(UNTRACKED, "TST"),
];
});
const ticking = page.tick();
await page.settle();
await page.click("btn-settings");
await setBox(page, "settings-use-blockscout", false);
answer();
await ticking;
expect(
page.storage.read("autistmask").wallets[0].addresses[0].balance,
).toBe("7.0");
expect(storedTokens(page)).toEqual([TRACKED]);
await page.click("btn-settings-back");
expect(page.node("address-balances").innerHTML).not.toContain(
UNTRACKED,
);
});
// Another page saves `change` over the stored profile. The browser is not
// made to tell this page, so this page sees the change only by reading the
// stored profile.
function anotherPageSaves(page, change) {
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
...change,
});
}
test.each([
["off", true, false],
["on", false, true],
])(
"after another page turns Use Blockscout %s, this page takes the change and its next refresh is saved",
async (_how, before, after) => {
const page = await bootPopup(onAddressDetail(before));
// The refresh answers as it does under the stored setting.
const { refreshBalances: held } = require("../src/shared/balances");
held.mockImplementationOnce(async (wallets) => {
const addr = wallets[0].addresses[0];
addr.balance = "7.0";
addr.tokenBalances = after
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
});
anotherPageSaves(page, { useBlockscout: after });
await page.tick();
expect(
page.storage.read("autistmask").wallets[0].addresses[0].balance,
).toBe("7.0");
expect(storedTokens(page)).toEqual(
after ? [TRACKED, UNTRACKED] : [TRACKED],
);
expect(page.hidden("address-tokens-note")).toBe(after);
},
);
test.each([
["off", true, false],
["on", false, true],
])(
"after another page turns Check recipients on Etherscan %s, this page's next Etherscan check follows the stored setting",
async (_how, before, after) => {
const page = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: before }),
);
anotherPageSaves(page, { checkRecipientsOnEtherscan: after });
const from = fetched().length;
require("../src/popup/views/confirmTx").show({
token: "ETH",
from: ADDRESS,
to: RECIPIENT,
amount: "0.1",
chainId: "0x1",
});
await page.settle();
// The page also takes the change and draws ConfirmTx again, which
// checks once more. So the test counts each URL once, however often
// it was asked for.
const asked = new Set(
fetched().slice(from).filter(blockscoutOrEtherscan),
);
expect([...asked]).toEqual(
after ? ["https://etherscan.io/address/" + RECIPIENT] : [],
);
},
);
// Another page's save of the whole profile with `change`, which the
// browser announces to this page as it announces any write.
async function anotherPageAnnounces(page, change) {
await page.storage.local.set({
autistmask: { ...page.storage.read("autistmask"), ...change },
});
await page.settle();
}
test("a page open on AddressDetail draws it again when another page turns Use Blockscout off", async () => {
const page = await bootPopup(onAddressDetail(true));
expect(page.hidden("address-tokens-note")).toBe(true);
await anotherPageAnnounces(page, { useBlockscout: false });
expect(page.visibleViews()).toEqual(["address"]);
expect(page.hidden("address-tokens-note")).toBe(false);
expect(page.node("address-balances").innerHTML).not.toContain(
UNTRACKED,
);
expect(storedTokens(page)).toEqual([TRACKED]);
});
test("a page open on ConfirmTx draws it again when another page turns Check recipients on Etherscan off", async () => {
const page = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
);
expect(page.hidden("confirm-etherscan-note")).toBe(true);
await anotherPageAnnounces(page, { checkRecipientsOnEtherscan: false });
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(page.hidden("confirm-etherscan-note")).toBe(false);
});
});
+4 -1
View File
@@ -446,7 +446,10 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
await require("../src/shared/state").loadState();
const { state, loadState } = require("../src/shared/state");
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
+2
View File
@@ -73,6 +73,8 @@ function loadColdWorker(networkId, opts) {
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: options.refreshBalances || jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
+8 -1
View File
@@ -108,7 +108,10 @@ global.fetch = jest.fn(() => {
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
@@ -243,6 +246,10 @@ async function openSend(token, tokenUnits = null) {
];
state.selectedWallet = 0;
state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of "Use Blockscout" over the stored one. So
// the page's copy is turned on here too.
state.useBlockscout = true;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
+7 -1
View File
@@ -50,8 +50,14 @@ function explorerPage(title) {
// The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
// The Etherscan label check is made, so its box can be shown.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
+12 -5
View File
@@ -142,13 +142,19 @@ jest.mock("../src/popup/views/txStatus", () => ({
}));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
// asks for are recorded, and nothing is fetched. The stored profile has the
// check turned on.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
@@ -220,6 +226,7 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const PASSWORD = "correct horse battery staple";
function el(id) {
@@ -312,7 +319,7 @@ async function switchReopenAndGoBack(reviewedOn, switchedTo) {
setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({});
prices.ETH = 2000;
prices.USDC = 1;
prices[USDC] = 1;
mockNode.asked = [];
global.fetch.mockClear();
goBack();
@@ -635,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 () => {
mockNode.tokenUnits[reviewedOn] = 50000000n;
await review(reviewedOn, {
token: TOKEN,
token: USDC,
tokenSymbol: "USDC",
tokenDecimals: 6,
amount: "10",
@@ -646,7 +653,7 @@ describe.each([
"ERC-20 token transfer (USDC)",
);
expect(el("confirm-token-contract").innerHTML).toContain(
`href="${explorerUrl}/address/${TOKEN}"`,
`href="${explorerUrl}/address/${USDC}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
+2
View File
@@ -297,6 +297,8 @@ describe.each([
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
test("a contract creation's row says so, with no colour dot and no address line", async () => {
+1 -1
View File
@@ -153,7 +153,7 @@ describe("the balance warning on the removal confirmation", () => {
test("the USD total is shown when prices are known", () => {
prices.ETH = 2000;
prices.USDC = 1;
prices[USDC] = 1;
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
});
+13 -1
View File
@@ -43,12 +43,15 @@ const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI;
const STUB_BLOCK_NUMBER = 21000000;
// A 32-byte zero word, returned for every eth_call. 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
// throw there reaches the console through src/shared/log.js, which fails the
// run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
// One ETH, so the popup's balance lines render something and the wallet does
// not look empty on the approval screen.
const STUB_BALANCE_WEI = 10n ** 18n;
@@ -111,6 +114,15 @@ function rpcResult(req, state) {
return latestBlock();
}
// A price feed's latestRoundData() gets five zero words, as in network.js:
// a price of zero, which the wallet counts as no price.
const data = String(
(req.params && req.params[0] && req.params[0].data) || "",
);
if (method === "eth_call" && data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
return "0x" + "0".repeat(64 * 5);
}
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
return RPC_RESULTS[method];
}
+53 -8
View File
@@ -867,6 +867,14 @@ step(
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await d.switchToWindow(env.popupWindow);
await d.waitFor(
"the open popup to show the testnet banner",
`const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";`,
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network");
@@ -881,6 +889,10 @@ step(
isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints",
);
await d.waitFor(
"the open popup to drop the testnet banner",
`return document.getElementById("debug-banner") === null;`,
);
await d.switchToWindow(env.dappWindow);
},
);
@@ -1023,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(
"closing an approval window rejects the request with 4001",
"a closed approval window leaves the request pending until it is rejected",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign-closed", "personal_sign", [
@@ -1041,15 +1056,45 @@ step(
// Closed, not rejected: this is the windows.onRemoved path, which can
// 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
// reason suppressing that write-back turns this step red.
// approval to be matched against. Without that id the toolbar button
// never opens the approval, and this step fails.
await d.closeWindow(env.dappWindow);
await assertUserRejection(d, "sign-closed", "a closed approval window");
const outcome = await settleRequest(d, "sign-closed", 2000);
assert(
env.server.broadcast.length === before,
"a closed approval window still put a transaction on the node",
outcome.settled === "pending",
"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);
}
},
);
+32 -8
View File
@@ -251,8 +251,13 @@ const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
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
// 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
@@ -273,6 +278,9 @@ function ethCallResult(req, opts) {
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
return "0x" + "0".repeat(64 * 5);
}
if (to !== STUB_TOKEN.address) return ZERO_WORD;
if (data.startsWith(SELECTOR_DECIMALS)) {
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
@@ -653,6 +661,12 @@ function traceEnabled(raw) {
* confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @param {string} [opts.etherscanLabel] the label Etherscan's page gives the
* address asked about, such as "Fake_Phishing1"; none when unset. Read at
* request time.
* @param {object[]} [opts.requests] a list for the requests this handler
* sees. Each is appended in order as { method, url, fromWorker }, whether it
* came from a popup page or the background worker.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/
@@ -691,6 +705,13 @@ async function installNetworkStubs(ctx, opts) {
const origin = fromWorker ? "[sw] " : "[page] ";
console.log("# routed " + origin + req.method() + " " + req.url());
}
if (Array.isArray(opts.requests)) {
opts.requests.push({
method: req.method(),
url: req.url(),
fromWorker,
});
}
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") {
@@ -751,23 +772,26 @@ async function installNetworkStubs(ctx, opts) {
}
}
// CoinDesk price tick
if (url.hostname.endsWith("coindesk.com")) {
return jsonResponse(route, { Data: {} });
}
// The interception canary's own request. Answered with nothing: what
// is being observed is that it arrived here at all.
if (url.href === WORKER_PROBE_URL) {
return route.fulfill({ status: 204, body: "" });
}
// Best-effort Etherscan address labels: served as an empty page.
// Best-effort Etherscan address labels: served as an empty page, or
// as a page labelling the address with opts.etherscanLabel.
if (url.hostname.endsWith("etherscan.io")) {
const title = opts.etherscanLabel
? "<title>" +
opts.etherscanLabel +
" | Address: " +
p.split("/").pop() +
" | Etherscan</title>"
: "";
return route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body></body></html>",
body: "<html><head>" + title + "</head><body></body></html>",
});
}
+487 -16
View File
@@ -269,6 +269,69 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
}
});
// ------------------ Blockscout and Etherscan are sent no user data (#668)
//
// A fresh install sends Blockscout and Etherscan none of the user's data until
// the user turns on "Use Blockscout" or "Check recipients on Etherscan". One
// request still reaches Blockscout while "Use Blockscout" is off: pressing
// "Save" beside the Blockscout URL asks that URL for `/stats`. That request
// carries no user data.
//
// The route handler in tests/e2e/network.js records in env.routeOpts.requests
// every request the extension makes, from its pages and its background worker.
// Recording starts when launch() in tests/e2e/harness.js installs that handler,
// before the first popup page opens. The background worker starts earlier, but
// it makes no request while it starts up.
//
// Before either setting is changed, these tests drive:
//
// - Home, AddressDetail, AddressToken and ConfirmTx
// - the background's own balance refresh
//
// Then they turn each setting on and watch its requests start. "Use Blockscout"
// is also turned off once and on again, to see an untracked token leave every
// list. TransactionDetail, which also asks Blockscout, is left to the unit
// tests in tests/blockscoutEtherscanSettings.test.js.
// The line shown in place of the three transaction histories while "Use
// Blockscout" is off. Written out here rather than imported, so the assertion
// does not compare the popup with itself.
const HISTORY_OFF =
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
// The requests made so far to Blockscout or Etherscan.
function explorerRequests(env) {
return env.routeOpts.requests.filter(({ url }) => {
const host = new URL(url).hostname;
return host.endsWith("blockscout.com") || host.endsWith("etherscan.io");
});
}
// Waits until the list `listId` shows the line saying "Use Blockscout" is off.
async function waitForHistoryOff(page, listId) {
await page.waitForFunction(
([id, want]) => document.getElementById(id).textContent.trim() === want,
[listId, HISTORY_OFF],
{ timeout: 15000 },
);
}
test("Welcome offers Use Blockscout and Check recipients on Etherscan, both unticked (#668)", async (env) => {
await visible(env.page, "#view-welcome");
for (const id of [
"welcome-use-blockscout",
"welcome-check-recipients-on-etherscan",
]) {
const st = await controlState(env.page, id);
assert(st !== null, "Welcome has no #" + id);
assert(
st.tag === "input" && st.type === "checkbox",
"#" + id + " is a <" + st.tag + " type=" + st.type + ">",
);
assert(st.checked === false, "#" + id + " is ticked on a new install");
}
});
// The address scan that follows creating the wallet is held, so the popup
// reloads while it is in flight. As for the reloads mid-refresh further down,
// the assertion for that is the harness's own: a console.error from the page
@@ -296,6 +359,261 @@ test("wallet creation through the UI reaches the main view, and reloading mid-sc
assert(addrCount > 0, "no addresses rendered in the wallet list");
});
test("with neither setting changed, Home, AddressDetail, AddressToken and ConfirmTx say what is off (#668)", async (env) => {
// Funded, so Send leads to the confirmation screen.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
try {
await visible(env.page, "#view-main");
await waitForHistoryOff(env.page, "home-tx-list");
await visible(env.page, "#home-tokens-note");
await env.page.waitForFunction(
(want) =>
document
.getElementById("wallet-list")
.textContent.includes(want),
quantity(FUNDED_ETH_WEI),
{ timeout: 60000 },
);
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await visible(env.page, "#address-tokens-note");
await env.page.locator('#address-balances [data-token="ETH"]').click();
await visible(env.page, "#view-address-token");
await waitForHistoryOff(env.page, "address-token-tx-list");
await env.page.click("#btn-address-token-back");
await visible(env.page, "#view-address");
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
await visible(env.page, "#confirm-etherscan-note");
assert(
!(await env.page.isVisible("#confirm-etherscan-warning")),
"ConfirmTx keeps the Etherscan warning's place while no Etherscan check is made",
);
await backToAddress(env.page);
} finally {
env.routeOpts.ethBalanceWei = null;
}
});
// The background refreshes balances on its alarm, once a minute. It skips a
// refresh when the stored record says one ran in the last 30 seconds. The open
// popup's own refresh runs every 10. So the popup is closed first. The test then
// has the background worker set the stored time of the last refresh to 0, so
// its next alarm runs a refresh.
test("with neither setting changed, nothing has reached Blockscout or Etherscan, the background's refresh included (#668)", async (env) => {
await waitForPersisted(
env.page,
"currentView",
"address",
"before the popup is closed",
);
await env.page.close();
const [worker] = env.ctx.serviceWorkers();
await worker.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = r.autistmask;
record.lastBalanceRefresh = 0;
chrome.storage.local.set({ autistmask: record }, resolve);
});
}),
);
const before = env.routeOpts.requests.length;
const deadline = Date.now() + 75000;
while (
!env.routeOpts.requests
.slice(before)
.some((r) => r.fromWorker && r.method === "POST")
) {
assert(
Date.now() < deadline,
"the background's balance refresh never asked the RPC endpoint",
);
await sleep(250);
}
// The refresh's RPC reads and any Blockscout request start together.
await sleep(3000);
const seen = explorerRequests(env);
assert(
seen.length === 0,
"a fresh install asked Blockscout or Etherscan: " +
JSON.stringify(seen),
);
// The watch saw the popup's own traffic as well as the worker's.
assert(
env.routeOpts.requests.some((r) => !r.fromWorker),
"no request from the popup was recorded",
);
// The popup reopens on the address screen the test above left it on.
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-address");
});
// Ticked from AddressDetail's gear, so Back lands on the screen that said the
// setting is off.
test("turning on Use Blockscout from AddressDetail's gear asks Blockscout for that screen's history on Back, then for the tokens (#668)", async (env) => {
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await openSettings(env.page);
await env.page.check("#settings-use-blockscout");
await waitForPersisted(env.page, "useBlockscout", true, "once ticked");
const historyAsked = env.page.waitForRequest(
(r) =>
r.url().includes("blockscout.com") &&
r.url().endsWith("/transactions"),
);
const tokensAsked = env.page.waitForRequest(
(r) => r.url().endsWith("/token-balances"),
{ timeout: 30000 },
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
await historyAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#address-tokens-note")),
"AddressDetail still says only tracked tokens are listed",
);
await goHome(env.page);
await tokensAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("home-tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#home-tokens-note")),
"Home still says only tracked tokens are listed",
);
});
// Unticked from AddressDetail's gear while the address holds the stub token,
// which the user has not tracked and only Blockscout reports. Ticked again at
// the end, so the rest of the run has Blockscout as before.
test("turning off Use Blockscout from AddressDetail's gear takes an untracked token off AddressDetail, Send and Home on Back (#668)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
const tokenRow = '[data-token="' + STUB_TOKEN.address + '"]';
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
await openAddressDetail(env.page);
await visible(env.page, "#address-balances " + tokenRow);
await openSettings(env.page);
await env.page.uncheck("#settings-use-blockscout");
await waitForPersisted(
env.page,
"useBlockscout",
false,
"once unticked",
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
await visible(env.page, "#address-tokens-note");
assert(
(await env.page
.locator("#address-balances " + tokenRow)
.count()) === 0,
"AddressDetail still lists the untracked token",
);
await env.page.click("#btn-send");
await visible(env.page, "#view-send");
await visible(env.page, "#send-tokens-note");
const offered = await env.page.$$eval("#send-token option", (opts) =>
opts.map((opt) => opt.value.toLowerCase()),
);
assert(
!offered.includes(STUB_TOKEN.address),
"Send's dropdown still offers the untracked token",
);
await env.page.click("#btn-send-back");
await visible(env.page, "#view-address");
await goHome(env.page);
await visible(env.page, "#home-tokens-note");
assert(
(await env.page.locator("#wallet-list " + tokenRow).count()) === 0,
"Home still lists the untracked token",
);
} finally {
await openSettings(env.page);
await env.page.check("#settings-use-blockscout");
await waitForPersisted(env.page, "useBlockscout", true, "ticked again");
await env.page.click("#btn-settings-back");
env.routeOpts.ethBalanceWei = null;
env.routeOpts.seedTokenBalance = false;
}
});
// Ticked from ConfirmTx's gear, so Back lands on the screen that said no
// Etherscan check was made. Left on for the rest of the run, so the later
// confirmation screens are driven with the check made.
test("turning on Check recipients on Etherscan from ConfirmTx's gear has ConfirmTx ask Etherscan on Back and show its label (#668)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.etherscanLabel = "Fake_Phishing668";
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI });
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await visible(env.page, "#confirm-etherscan-note");
await openSettings(env.page);
await env.page.check("#settings-check-recipients-on-etherscan");
await waitForPersisted(
env.page,
"checkRecipientsOnEtherscan",
true,
"once ticked",
);
const before = env.routeOpts.requests.length;
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-confirm-tx");
await visible(env.page, "#confirm-etherscan-warning", 30000);
assert(
!(await env.page.isVisible("#confirm-etherscan-note")),
"ConfirmTx says no Etherscan check was made, with the check on",
);
const asked = env.routeOpts.requests
.slice(before)
.map((r) => r.url.toLowerCase())
.filter((url) => url.includes("etherscan.io"));
assert(
asked.length === 1 &&
asked[0] ===
"https://etherscan.io/address/" + STUB_COUNTERPARTY,
"ConfirmTx asked Etherscan for " + JSON.stringify(asked),
);
await backToAddress(env.page);
} finally {
env.routeOpts.ethBalanceWei = null;
env.routeOpts.etherscanLabel = null;
}
});
test("add token screen opens from address detail (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
@@ -3480,6 +3798,24 @@ function hostileSymbolState(page, tokenAddress) {
}
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
// The stub token is no longer tracked from here on, so the explorer is
// what lists it, as it lists a token airdropped to the victim. A tracked
// token is listed from the RPC endpoint under the symbol it was added
// with, which the hostile symbol below never reaches
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
await env.page.click("#settings-tracked-tokens .btn-remove-token");
// The list is drawn again once the removal is saved.
await env.page.waitForFunction(
() =>
document
.getElementById("settings-tracked-tokens")
.innerText.trim() === "None",
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
@@ -3774,11 +4110,12 @@ async function waitForApprovalWindow(ctx, timeout = 30000) {
// reservation exists.
// 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
// disconnects its port, and src/background/index.js calls resetPopupUrl() on
// that disconnect; issued too early, the next request's setPopup() is undone
// by the previous popup's teardown, chrome.action.openPopup() then rejects,
// and the fallback window it opens dismisses the popup that was just raised —
// which settles the fresh approval as a rejection before it can be seen.
// disconnects its port, and src/background/index.js calls
// updateToolbarPopup() on that disconnect; issued too early, the next
// request's setPopup() is undone by the previous popup's teardown,
// chrome.action.openPopup() then rejects, and the fallback window it opens
// 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.
const APPROVAL_TAB_SETTLE_MS = 1500;
@@ -3804,6 +4141,17 @@ async function reserveApprovalTab(env) {
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
// to open one, and the reserved tab at the action's approval URL otherwise.
async function openSiteApprovalPopup(env, timeout = 30000) {
@@ -3814,12 +4162,7 @@ async function openSiteApprovalPopup(env, timeout = 30000) {
.find((p) => !p.isClosed() && p.url().includes("?approval="));
if (existing) return existing;
const url = await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.action.getPopup({}, (u) => resolve(String(u)));
}),
);
const url = await toolbarPopupUrl(env);
if (url.includes("?approval=")) {
await env.approvalTab.goto(url);
return env.approvalTab;
@@ -4691,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
// 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
@@ -4734,8 +5187,8 @@ test("a prompt raised while another approval window has focus opens its own (#29
});
await visible(txWindow, "#view-approve-tx");
// Closing a window is refusing its prompt, so both answer 4001.
await closeApprovalPages(env.ctx);
await clickAndClose(txWindow, "#btn-reject-tx");
await clickAndClose(signWindow, "#btn-reject-sign");
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
});
@@ -4865,6 +5318,12 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await env.page.waitForFunction(() => {
const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";
});
const restored = await openNetworkPrompt(
env,
@@ -4886,6 +5345,9 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
await env.page.waitForFunction(
() => document.getElementById("debug-banner") === null,
);
});
// Switching the network in Settings tells the page too, as an approved site
@@ -5231,10 +5693,17 @@ async function main() {
seedReceipt: false,
// Whether eth_getTransactionReceipt fails instead of answering (#315).
failReceiptLookup: false,
// The label Etherscan's page gives an address, when it gives one
// (#668).
etherscanLabel: null,
// Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.
broadcastTransactions: [],
// Every request the extension makes, in order, from when launch()
// installs the route handler, before the first popup page opens
// (#668).
requests: [],
};
let session;
@@ -5326,9 +5795,11 @@ async function main() {
// 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
// have closed itself, and closing it here unreported would hide that.
// After a test that already failed, they are closed unreported, so
// that a prompt it left unanswered does not refuse the next one from
// its site.
// After a test that already failed, they are closed unreported. That
// rejects a site-connection or network-switch prompt it left
// 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) {
const deadline = Date.now() + 5000;
let open;
+114
View File
@@ -0,0 +1,114 @@
// ENS lookups ask nothing but the RPC endpoint. A resolver can answer that a
// name is resolved off-chain, naming a web address to fetch the answer from.
// ethers fetches that address unless the provider has `disableCcipRead` set.
// That request would carry the name to a server other than the RPC endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/668). With the flag set, such a
// name does not resolve.
//
// The test builds its provider with getProvider() and runs it against a fake
// JSON-RPC node. The fake node answers through ethers' own request hook. The
// hook also records the address of every request ethers makes, the off-chain
// fetch included.
const { FetchRequest, Interface } = require("ethers");
const { getProvider } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const RESOLVER = "0x5555555555555555555555555555555555555555";
const USER = "0x1111111111111111111111111111111111111111";
const NAME = "offchain.eth";
// The ENS registry's resolver(), and the resolver's functions ethers calls:
//
// - the registry names RESOLVER for every name
// - the resolver says it resolves names through resolve()
// - it gives NAME as the reverse record of any address
// - it answers resolve() with the OffchainLookup error, which says the name
// is resolved off-chain
const ens = new Interface([
"function resolver(bytes32) view returns (address)",
"function supportsInterface(bytes4) view returns (bool)",
"function name(bytes32) view returns (string)",
"function resolve(bytes, bytes) view returns (bytes)",
"error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData)",
]);
let asked;
// One JSON-RPC answer. Anything but a call to one of the functions above is
// refused.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
const call =
req.method === "eth_call" ? ens.parseTransaction(req.params[0]) : null;
if (call === null) {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
if (call.name === "resolve") {
const offchain = ens.encodeErrorResult("OffchainLookup", [
RESOLVER,
["https://gateway.example.invalid/{sender}/{data}.json"],
"0x1234",
"0x12345678",
"0x",
]);
return {
...envelope,
error: { code: 3, message: "execution reverted", data: offchain },
};
}
const answers = {
resolver: [RESOLVER],
supportsInterface: [true],
name: [NAME],
};
return {
...envelope,
result: ens.encodeFunctionResult(call.name, answers[call.name]),
};
}
beforeEach(() => {
asked = [];
FetchRequest.registerGetUrl(async (req) => {
asked.push(req.url);
// Any other address is the off-chain fetch, answered as not found.
if (req.url !== RPC_URL) {
return {
statusCode: 404,
statusMessage: "Not Found",
headers: {},
body: null,
};
}
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
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());
});
test("a name typed into Send that is resolved off-chain does not resolve, and only the RPC endpoint is asked", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const [outcome] = await Promise.allSettled([provider.resolveName(NAME)]);
expect(asked.filter((url) => url !== RPC_URL)).toEqual([]);
expect(outcome).toEqual({ status: "fulfilled", value: null });
});
test("the reverse lookup of a user's address whose name is resolved off-chain finds no name, and only the RPC endpoint is asked", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const [outcome] = await Promise.allSettled([provider.lookupAddress(USER)]);
expect(asked.filter((url) => url !== RPC_URL)).toEqual([]);
expect(outcome).toEqual({ status: "fulfilled", value: null });
});
+7 -1
View File
@@ -35,8 +35,11 @@ global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
@@ -165,6 +168,9 @@ beforeEach(() => {
debugFetch.mockReset();
state.trackedTokens = [];
state.wallets = [];
// The approval screens read the explorer's report only while the page's
// "Use Blockscout" is on.
state.useBlockscout = true;
});
describe("what fetchTokenBalances stores for an absent scale", () => {
+493
View File
@@ -0,0 +1,493 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
+5 -1
View File
@@ -21,7 +21,11 @@ jest.mock("../src/shared/log", () => ({
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
global.chrome = { storage: { local: {} } };
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const {
LOW_HOLDER_THRESHOLD,
+9
View File
@@ -165,6 +165,15 @@ describe("declared icons", () => {
});
});
// Firefox gives a page in a private window locks of its own, so that page's
// save could land between the background's read and its write (README,
// "Stored state and its version").
describe("private windows", () => {
test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed");
});
});
describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs.
+7
View File
@@ -436,6 +436,13 @@ describe.each([
});
test("the transaction detail screen's type line and fee", async () => {
// The fee is Blockscout's, asked for only while the stored "Use
// Blockscout" is on.
state.useBlockscout = true;
global.chrome.storage.write("autistmask", {
...global.chrome.storage.read("autistmask"),
useBlockscout: true,
});
debugFetch.mockImplementationOnce(async () => ({
ok: true,
status: 200,
+5 -1
View File
@@ -286,8 +286,12 @@ describe("a malformed fraudContracts", () => {
function stubSendDocument() {
const select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null),
getElementById: (id) => elements[id] || null,
createElement: () => ({ value: "", textContent: "" }),
};
return select;
+15
View File
@@ -629,6 +629,21 @@ const CONTRACT = [
// the falsy answer is also the DEFAULT_STATE default.
falsy: [0],
})),
// Anything but true is off for these three settings:
// - useBlockscout and checkRecipientsOnEtherscan let a request reach
// another service.
// - allowWeakPasswords lets AddWallet accept a short password.
...[
"useBlockscout",
"checkRecipientsOnEtherscan",
"allowWeakPasswords",
].map((field) => ({
field,
kind: KIND.SCALAR,
hostile: [42, "notabool", { a: 1 }],
floorOnly: [0, null, "true"],
holds: (v) => v === false,
})),
];
function siteMapHolds(v) {
+5 -1
View File
@@ -44,7 +44,9 @@ test("a flash message's timer does not fire after cleanupPopup()", async () => {
// A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559).
// (https://git.eeqj.de/sneak/AutistMask/issues/559). TransactionDetail asks
// Blockscout only while "Use Blockscout" is on. ConfirmTx asks Etherscan only
// while "Check recipients on Etherscan" is on. So the profile turns both on.
function restoringOnto(view, viewData) {
return unversionedValidProfile({
currentView: view,
@@ -52,6 +54,8 @@ function restoringOnto(view, viewData) {
selectedAddress: 0,
viewStack: ["main"],
viewData,
useBlockscout: true,
checkRecipientsOnEtherscan: true,
});
}
+565
View File
@@ -0,0 +1,565 @@
// An open popup follows a network switch the background saves once the user
// approves a site's request: it takes the new network from the stored record
// and draws the screen it is on for it, as a switch in Settings does
// (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real
// popup over a stored profile on Ethereum Mainnet. The background's write is
// the storage stub's set, which the stub reports to the page as the browser
// does.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const { NETWORKS } = require("../src/shared/networks");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
afterEach(() => {
cleanupPopup();
});
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail,
// with DAI tracked there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() {
return bootPopup(profileOnAddress());
}
// The background saves a switch to `networkId`, as its updateState() does
// once the user approves a site's request.
async function backgroundSwitch(page, networkId) {
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, networkId);
await page.storage.local.set({ autistmask: record });
await page.settle();
await page.settle();
}
test("the popup open on AddressDetail shows the new network and its balances", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("DAI");
expect(page.node("debug-banner")).toBeNull();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).toContain("SepoliaETH");
expect(balances).not.toContain("DAI");
expect(page.text("debug-banner")).toBe("[TESTNET]");
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
});
test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["settings"]);
expect(page.value("settings-network")).toBe("sepolia");
expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST");
expect(page.text("debug-banner")).toBe("[TESTNET]");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).not.toContain("DAI");
});
// Neither can be drawn again for the new network: Send cannot be rendered from
// saved state, and DAI was picked on Ethereum Mainnet, where alone it is
// tracked. A reopened popup goes to Home in their place.
test.each([
["Send", (page) => page.click("btn-send")],
[
"AddressToken for DAI",
async (page) => {
const { state } = require("../src/shared/state");
state.selectedToken = MAINNET_TOKEN.address;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
},
],
])("the popup open on %s goes to Home", async (_view, open) => {
const page = await bootOnAddress();
await open(page);
expect(page.visibleViews()).not.toEqual(["address"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
});
// The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
const writes = set.mock.calls.length;
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.settle();
expect(get).toHaveBeenCalledTimes(reads + 1);
expect(set).toHaveBeenCalledTimes(writes + 1);
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
});
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("mainnet");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the
// URL is stored, the background saves a switch to Sepolia. Another write holds
// the lock, so the popup's follow of that switch and the URL's save both wait
// for the lock. Either may take it first. The URL is stored for neither
// network. The flash message says that the network changed.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc", first: "follow" },
{ button: "btn-save-rpc", field: "settings-rpc", first: "save" },
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "follow",
},
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "save",
},
])(
"$button saves nothing when the network changes before the URL is stored ($first first)",
async ({ button, field, first }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks. The RPC check reads
// Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The click handler runs until its save waits for the lock.
const tapSave = async () => {
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
return saving;
};
let saving;
if (first === "save") saving = await tapSave();
await backgroundSwitch(page, "sepolia");
if (first === "follow") saving = await tapSave();
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The popup's follow of the background's switch to Sepolia has set `state` to
// Sepolia. The follow's write has not returned yet, so Settings still shows
// Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum
// Mainnet, so the save stores the URL for neither network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing when tapped while the follow's write waits",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
const { set } = page.storage.local;
const write = set.getMockImplementation();
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
// The background's write lands; the next, the follow's, waits.
set.mockImplementationOnce(write).mockImplementationOnce(
async (items) => {
await writeHeld;
await write(items);
},
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(page.value("settings-network")).toBe("mainnet");
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseWrite();
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for
// the node's answer, the popup follows the background's switch to Sepolia. The
// node answers with Sepolia's chain id. So the check refuses the node for
// Ethereum Mainnet, and nothing is stored.
test("the RPC check compares the answer with the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://sepolia-node.example.invalid";
page.node("settings-rpc").value = url;
const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"Wrong network: expected Ethereum Mainnet.",
);
});
// The user taps the Blockscout "Save" on Ethereum Mainnet. While the check
// waits for Blockscout's answer, the popup follows the background's switch to
// Sepolia. The URL is for Ethereum Mainnet, so nothing is stored.
test("the Blockscout save is for the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://own-explorer.example.invalid/api/v2";
page.node("settings-blockscout").value = url;
const saving = page
.node("btn-save-blockscout")
.listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
expect(page.value("settings-network")).toBe("sepolia");
answer({ ok: true });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
});
// Settings is open when the network changes to Sepolia. The popup follows the
// background's switch, or the user picks Sepolia in the network selector. Each
// "Save" after the switch stores its URL for Sepolia.
test.each([
["a follow", (page) => backgroundSwitch(page, "sepolia")],
[
"the network selector",
async (page) => {
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
},
],
])(
"after a switch to Sepolia by %s, Settings saves each URL for Sepolia",
async (_how, switchToSepolia) => {
const page = await bootOnAddress();
await page.click("btn-settings");
await switchToSepolia(page);
// This answer passes both endpoint checks on Sepolia. The RPC check
// reads Sepolia's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.sepolia.chainId }),
});
const rpcUrl = "https://own-node.example.invalid";
const blockscoutUrl = "https://own-explorer.example.invalid/api/v2";
page.node("settings-rpc").value = rpcUrl;
await page.click("btn-save-rpc");
expect(page.text("flash-msg")).toBe("Saved.");
page.node("settings-blockscout").value = blockscoutUrl;
await page.click("btn-save-blockscout");
expect(page.text("flash-msg")).toBe("Saved.");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.rpcUrl).toBe(rpcUrl);
expect(stored.blockscoutUrl).toBe(blockscoutUrl);
},
);
// The user picks Sepolia in Settings, but the switch's write fails. The page is
// then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save"
// after the failed switch stores nothing for either network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing after a switch in Settings whose write failed",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
page.storage.local.set.mockRejectedValueOnce(
new Error("quota exceeded"),
);
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) {
await expect(fn()).rejects.toThrow("quota exceeded");
}
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
page.node(field).value = url;
await page.click(button);
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
test("a record that names no network is not followed", async () => {
const page = await bootOnAddress();
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
await set({
autistmask: {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [{ type: "hd", name: "Main", addresses: [] }],
},
});
await page.settle();
expect(get).toHaveBeenCalledTimes(reads);
expect(page.visibleViews()).toEqual(["address"]);
});
// A record that takes the switch from the switched record ends up as that
// record, balances the background fetched since included.
test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => {
const before = unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
networkEndpoints: {
sepolia: {
rpcUrl: "http://127.0.0.1:8545",
blockscoutUrl: "http://127.0.0.1:4000/api/v2",
},
},
lastBalanceRefresh: 1,
tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 },
fraudContracts: ["0x1111111111111111111111111111111111111111"],
});
const switched = structuredClone(before);
applyChainSwitchFields(switched, "sepolia");
switched.wallets[0].addresses[0].balance = "2.0";
const follower = structuredClone(before);
copyChainSwitchFields(follower, switched);
expect(follower).toEqual(switched);
});
+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([]);
});
});
+5 -1
View File
@@ -90,7 +90,7 @@ let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub(),
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: {
connect: () => ({
postMessage() {},
@@ -220,6 +220,10 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of "Use Blockscout" over the stored one. So
// the page's copy is turned on here too.
state.useBlockscout = true;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
+8 -1
View File
@@ -100,7 +100,10 @@ global.fetch = jest.fn(() => {
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
@@ -213,6 +216,10 @@ async function refreshWith(balanceWei, tokenItems = []) {
];
state.selectedWallet = 0;
state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of "Use Blockscout" over the stored one. So
// the page's copy is turned on here too.
state.useBlockscout = true;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
+5 -1
View File
@@ -27,8 +27,12 @@ let select;
function installStubDocument() {
select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null),
getElementById: (id) => elements[id] || null,
createElement: () => ({ value: "", textContent: "" }),
};
}
+49
View File
@@ -0,0 +1,49 @@
// 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.");
},
);
+36 -7
View File
@@ -23,6 +23,9 @@
// version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working.
const fs = require("fs");
const path = require("path");
const {
bootPopup,
cleanupPopup,
@@ -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", () => {
// 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
@@ -523,13 +549,16 @@ describe("a garbage value in a field the gate does not check", () => {
});
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
const env = await bootPopup(
profileWithTokenBalances([
null,
42,
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]),
);
// "Use Blockscout" is on. Otherwise the boot's balance refresh would
// drop the well-formed entry too, since the user has not tracked its
// token.
const profile = profileWithTokenBalances([
null,
42,
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]);
profile.useBlockscout = true;
const env = await bootPopup(profile);
const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
+2 -3
View File
@@ -6,9 +6,8 @@
// now holds a lock from its read to its write.
//
// The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and a page outside
// a Firefox private window share the browser's
// (tests/support/navigatorLocks.js).
// storage stub, and share the lock the way the background and its pages share
// the browser's (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub");
+2 -3
View File
@@ -4,9 +4,8 @@
// the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's,
// except a page in a Firefox private window (README, "Stored state and its
// version").
// shares it, as the extension's background and its pages share the browser's
// (README, "Stored state and its version").
//
// Run before each test file by `setupFiles` in package.json.
+3 -1
View File
@@ -283,6 +283,8 @@ async function bootPopup(stored, options) {
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}),
scanForAddresses: jest.fn(async () => ({
@@ -311,7 +313,7 @@ async function bootPopup(stored, options) {
const windowListeners = {};
globalThis.chrome = {
storage: { local: storage.local },
storage: { local: storage.local, onChanged: storage.onChanged },
runtime: {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
+30 -5
View File
@@ -18,7 +18,9 @@
// a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
// `changed` is handed what each set or remove changed, in the shape
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -34,14 +36,25 @@ function makeArea(store, tick) {
const set = jest.fn(async (items) => {
tick();
const changes = {};
for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]);
}
changed(changes);
});
const remove = jest.fn(async (key) => {
tick();
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
const changes = {};
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
});
return { get, set, remove };
@@ -54,12 +67,22 @@ function makeArea(store, tick) {
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
// storage.onChanged: after a set or remove has finished, every listener is
// called with what it changed and the area's name, as the browser calls
// them for a write by any page or the background.
const listeners = [];
const announce = (areaName) => (changes) => {
Promise.resolve().then(() => {
for (const fn of listeners) fn(structuredClone(changes), areaName);
});
};
const local = makeArea(store, tick, announce("local"));
return {
// Drop this straight in as chrome.storage.
local,
session: makeArea({}, tick),
session: makeArea({}, tick, announce("session")),
onChanged: { addListener: (fn) => listeners.push(fn) },
get: local.get,
set: local.set,
remove: local.remove,
@@ -71,7 +94,9 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this".
// test — for standing in as "another page wrote this". Nothing is
// announced to storage.onChanged, so it also stands in for the time
// before the browser tells this page of such a write.
write: (key, value) => {
store[key] = structuredClone(value);
},
+12 -2
View File
@@ -52,8 +52,14 @@ jest.mock("../src/shared/log", () => ({
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// Blockscout is asked only while the stored "Use Blockscout" is on.
global.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
};
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
@@ -485,8 +491,12 @@ describe("surface 2: the Send token selector", () => {
function render(tokenBalances) {
select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null),
getElementById: (id) => elements[id] || null,
createElement: () => ({ value: "", textContent: "" }),
};
renderSendTokenSelect({
+2
View File
@@ -134,6 +134,8 @@ beforeEach(() => {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
describe.each([
+8 -1
View File
@@ -38,8 +38,14 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false,
}));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
};
const { state } = require("../src/shared/state");
@@ -158,6 +164,7 @@ beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.useBlockscout = true;
state.networkEndpoints = {};
state.wallets = [
{
+309
View File
@@ -0,0 +1,309 @@
// The balance of each token the user tracks is read with balanceOf through the
// RPC endpoint, for every address. Blockscout's answer gives a tracked token its
// holder count and decimals, and adds the tokens the user has not tracked
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
//
// The real refreshBalances() runs against a fake JSON-RPC node, answered
// through ethers' own request hook, and a fake Blockscout, answered through
// fetch().
const { AbiCoder, FetchRequest } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { makeStorageStub } = require("./support/storageStub");
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const FIRST = "0x1111111111111111111111111111111111111111";
const SECOND = "0x2222222222222222222222222222222222222222";
// A token the user tracks, outside the bundled list, at 6 decimals.
const TOKEN = "0x7777777777777777777777777777777777777777";
const TRACKED = {
address: TOKEN,
symbol: "TRK",
name: "Tracked Token",
decimals: 6,
};
// USDC, from the bundled list, which the user does not track.
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
// Neither on the bundled list nor tracked.
const OTHER = "0x8888888888888888888888888888888888888888";
const ZERO_WORD = "0x" + "0".repeat(64);
const BALANCE_OF = "0x70a08231";
// What balanceOf answers, by token contract and then by holder. A pair that is
// not here makes balanceOf fail.
let holdings;
// What Blockscout answers for every address; null makes the request fail.
let explorerRows;
let logged;
// One JSON-RPC answer. A call that is not balanceOf is the ENS reverse lookup,
// answered with no resolver, so no name.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
if (req.method === "eth_getBalance") {
return { ...envelope, result: "0x0" };
}
if (req.method !== "eth_call") {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
const { to, data } = req.params[0];
if (!data.startsWith(BALANCE_OF)) return { ...envelope, result: ZERO_WORD };
const holder = "0x" + data.slice(-40);
const amount = (holdings[to.toLowerCase()] || {})[holder];
if (amount === undefined) {
return {
...envelope,
error: { code: -32000, message: "fixture: balanceOf failed" },
};
}
return {
...envelope,
result: AbiCoder.defaultAbiCoder().encode(["uint256"], [amount]),
};
}
const realFetch = globalThis.fetch;
beforeEach(() => {
holdings = {};
explorerRows = null;
logged = [];
jest.spyOn(console, "error").mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
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)),
};
});
globalThis.fetch = jest.fn(async () => {
if (explorerRows === null) throw new TypeError("Failed to fetch");
return { ok: true, status: 200, json: async () => explorerRows };
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
jest.restoreAllMocks();
});
// One Blockscout token-balances row.
function explorerRow(address, symbol, value, holders = "50000") {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: address,
symbol: symbol,
name: symbol,
decimals: "6",
holders_count: holders,
},
};
}
// The row refreshBalances() writes for TOKEN from a balanceOf answer, with the
// decimals and holder count Blockscout reported for it, if any.
function trackedRow(balance, decimals = null, holders = null) {
return {
address: TOKEN,
name: "Tracked Token",
symbol: "TRK",
decimals: decimals,
balance: balance,
holders: holders,
};
}
async function refresh(addresses, trackedTokens) {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
trackedTokens,
"mainnet",
new AbortController().signal,
);
return addresses;
}
test("with Blockscout failing, a tracked token's balance is read through the RPC endpoint for every address", async () => {
holdings[TOKEN] = { [FIRST]: 1234500000n, [SECOND]: 1n };
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("1234.5")]);
// Exact, never cut: one base unit is not zero.
expect(second.tokenBalances).toEqual([trackedRow("0.000001")]);
});
test("with Blockscout failing, the untracked rows from before stay", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const usdc = {
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [usdc] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0"), usdc]);
});
test("a tracked token Blockscout also reports shows the RPC figure, and an untracked one is admitted as before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [
explorerRow(TOKEN, "TRK", 9000000),
explorerRow(USDC, "USDC", 5000000),
// Too few holders, and neither on the bundled list nor tracked.
explorerRow(OTHER, "SPAM", 5000000, "10"),
// A known symbol from a contract it does not belong to.
explorerRow(OTHER, "USDC", 5000000),
];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
trackedRow("2.0", 6, 50000),
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
]);
});
test("a tracked token's entry carries the holder count and decimals Blockscout reports for it", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
// Fewer holders than "Hide tokens with fewer than 1,000 holders" lets
// through to Send's token list.
explorerRows = [explorerRow(TOKEN, "TRK", 9000000, "10")];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("while Blockscout fails, a tracked token keeps the holder count and decimals of its entry from before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0", 6, 10)] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("a failed balanceOf keeps the token's entry from before, and is logged", async () => {
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const before = trackedRow("7.0", 6, 50000);
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([before]);
expect(logged.some((line) => line.includes("token balance failed"))).toBe(
true,
);
});
test("a failed balanceOf with no entry from before takes Blockscout's entry from this refresh", async () => {
// balanceOf fails at FIRST and answers at SECOND.
holdings[TOKEN] = { [SECOND]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
{
address: TOKEN,
name: "TRK",
symbol: "TRK",
decimals: 6,
balance: "9.0",
holders: 50000,
},
]);
// Where balanceOf answers, its figure stands.
expect(second.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a balance of zero read through the RPC endpoint replaces the previous figure", async () => {
holdings[TOKEN] = { [FIRST]: 0n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0")] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([]);
});
test("a tracked token whose scale nothing knows gets no quantity, not a zero", async () => {
holdings[TOKEN] = { [FIRST]: 5n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow(null)]);
});
test("a tracked token whose scale only Blockscout reports is stated at that scale", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a tracked token wearing a known symbol from another contract is not listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
// Its entry from before goes too, although Blockscout fails.
const before = {
address: OTHER,
name: "Fake",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }],
);
expect(first.tokenBalances).toEqual([]);
});
+74
View File
@@ -161,6 +161,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
@@ -176,6 +177,78 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
expect(onSepolia).not.toContain("DAI");
});
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch's save writes only the token lists that this page changed.
// So DAI stays in `trackedTokens`, the active network's list.
test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
@@ -286,6 +359,7 @@ describe("Back after a switch from the Settings gear", () => {
currentView: "confirm-tx",
selectedWallet: 0,
selectedAddress: 0,
checkRecipientsOnEtherscan: true,
viewData: {
pendingTx: {
token: "ETH",
+2
View File
@@ -125,6 +125,8 @@ beforeEach(() => {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
afterEach(() => {
+2
View File
@@ -1215,6 +1215,8 @@ function respondWith(txItems, tokenTransferItems) {
describe("fetchRecentTransactions merge and dedup", () => {
beforeEach(() => {
debugFetch.mockReset();
// Blockscout is asked only while the stored "Use Blockscout" is on.
global.chrome.storage.write("autistmask", { useBlockscout: true });
});
test("queries only the two Blockscout endpoints for the address", async () => {
+1180 -57
View File
File diff suppressed because it is too large Load Diff
+516 -40
View File
@@ -11,7 +11,8 @@
// what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an
// UNWRAP_WETH makes the output ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/607).
// (https://git.eeqj.de/sneak/AutistMask/issues/607 and
// https://git.eeqj.de/sneak/AutistMask/issues/642).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -41,8 +42,14 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false,
}));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
};
const uniswap = require("../src/shared/uniswap");
@@ -681,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.
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(
@@ -745,6 +808,365 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/642: a step between the swap and
// the UNWRAP_WETH may take WETH out of the router or pay it WETH, and a step
// after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each
// route read as ETH with the swap's minimum. A step the decoder read that is a
// PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION of a token other than WETH, and not of ETH to a
// WETH contract, cannot take WETH out of the router or pay anyone WETH; the
// decoder treats every other step as one that may.
describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
(l) => l !== "Min. received",
);
const NO_MINIMUM_ELSEWHERE = PAID_ELSEWHERE.filter(
(l) => l !== "Min. paid out",
);
test.each([
{
kind: "a TRANSFER of 0.0025 WETH",
step: transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips",
step: transfer(0x06, WETH, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15",
step: transfer(0x07, WETH, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with no minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test.each([
{
kind: "a SWEEP whose recipient cannot be read",
step: withDirtyWord(sweep(WETH, OTHER), 1),
},
{
kind: "a V4 SETTLE of 0.5 WETH from the router",
step: v4Swap([
0x0b,
coder.encode(
["address", "uint256", "bool"],
[WETH, ONE_WETH / 2n, false],
),
]),
},
{
kind: "a bridge deposit of 0.5 WETH",
step: acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
},
{
kind: "a PERMIT2_TRANSFER_FROM of 0.5 WETH into the router",
step: permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH / 2n),
},
])("$kind between them reads as ETH with no minimum", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
test.each([
{
kind: "a PERMIT2_PERMIT between them",
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 of another address between them",
steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 of the sender with a zero minimum between them",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "a SWEEP of USDC between them",
steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)],
},
{
kind: "an UNWRAP_WETH into the router, then a SWEEP of its ETH,",
steps: [unwrapWeth(ADDRESS_THIS), sweep(ZERO, MSG_SENDER)],
},
])("$kind reads as ETH with the swap's minimum", ({ steps }) => {
const result = decode(execute(swapToWeth(ADDRESS_THIS), ...steps));
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test("a V4 step that TAKEs its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH with the swap's minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test.each([
{
kind: "a TRANSFER of 100 USDC",
step: transfer(0x05, USDC, OTHER, 100000000n),
},
{
kind: "a PAY_PORTION of 25 bips of USDC",
step: transfer(0x06, USDC, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of USDC",
step: transfer(0x07, USDC, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
test.each([
{
kind: "a TRANSFER of 0.0025 ETH",
step: transfer(0x05, ZERO, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips of its ETH",
step: transfer(0x06, ZERO, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of its ETH",
step: transfer(0x07, ZERO, OTHER, 10n ** 15n),
},
])(
"an UNWRAP_WETH into the router, then $kind to another address, then a SWEEP of its ETH to the user, reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
sweep(ZERO, MSG_SENDER),
),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
sweep(WETH, MSG_SENDER),
wrapEth(ADDRESS_THIS, ONE_WETH),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The router reads the recipient from the last 20 bytes of its word,
// here address(2), the router itself.
test("an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// The first UNWRAP_WETH may pay the router the swap's output as ETH, and
// the WRAP_ETH to the user pays all of it out as WETH, so the last
// UNWRAP_WETH finds none of it.
test.each([
{
kind: "an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user",
steps: [
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
],
},
{
kind: "an UNWRAP_WETH into the router, a WRAP_ETH of all its ETH to the user, then a WRAP_ETH of all its ETH into the router",
steps: [
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
],
},
])(
"$kind, then an UNWRAP_WETH to the user, reads as WETH with no minimum",
({ steps }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
...steps,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A SWEEP of WETH pays out WETH, and ETH paid to a WETH contract pays the
// router WETH for it. The decoder treats each as a step that may pay out
// as WETH the swap's output, which the router holds as ETH.
test.each([
{
kind: "a SWEEP of WETH to the user",
step: sweep(WETH, MSG_SENDER),
},
{
kind: "a SWEEP of its ETH to the WETH contract",
step: sweep(ZERO, WETH),
},
{
kind: "a TRANSFER of 0.5 ETH to the WETH contract",
step: transfer(0x05, ZERO, WETH, ONE_WETH / 2n),
},
{
kind: "a PAY_PORTION of all its ETH to the WETH contract",
step: transfer(0x06, ZERO, WETH, 10000n),
},
])(
"an UNWRAP_WETH into the router, then $kind, reads as WETH with no minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, ONE_WETH / 2n),
sweep(ZERO, MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
// the ETH an earlier V4 step paid the router.
test("an UNWRAP_WETH to the user, then a WRAP_ETH of all the router's ETH to the user, reads as ETH", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The first swap is for at least 0.5 WETH and the second for at least
// 1 WETH. The second sets the output side again, so the first
// UNWRAP_WETH into the router no longer bears on it.
test.each([
{ kind: "a V3 swap step", step: swapToWeth(ADDRESS_THIS) },
{
kind: "a V4 step that TAKEs its WETH to the router",
step: v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
},
])(
"a swap paying WETH to the router, an UNWRAP_WETH into the router, a SWEEP of its ETH to the user, then $kind and an UNWRAP_WETH to the user, reads as ETH with the second swap's minimum",
({ step }) => {
const result = decode(
execute(
v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
ONE_WETH / 2n,
USDT,
WETH,
),
unwrapWeth(ADDRESS_THIS),
sweep(ZERO, MSG_SENDER),
step,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
},
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
// router stays there unless a later step pays out or spends all of it, not
// only the swap's output. A V4 SETTLE can take more of the user's tokens than
@@ -972,6 +1394,10 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
// The second swap action spends 1 USDC of what the first buys,
// so the step states no minimum
// (https://git.eeqj.de/sneak/AutistMask/issues/574).
noMin: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles",
@@ -1072,15 +1498,24 @@ describe("any token left in the router is not received", () => {
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left, spent, unread }) => {
])("$kind", ({ data, left, spent, unread, noMin }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625),
// after the lines the WRAP_ETH's 1 ETH then gets
// (https://git.eeqj.de/sneak/AutistMask/issues/659).
...(unread
? [
"Moved from the sender",
"Token moved",
"Moved to",
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
...(spent
@@ -1089,7 +1524,7 @@ describe("any token left in the router is not received", () => {
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
...(noMin ? [] : ["Min. paid out"]),
"Steps",
"Deadline",
]);
@@ -1097,13 +1532,17 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
expect(
result.details
.filter((d) => d.label === "Moved from the sender")
.map((d) => d.value),
).toEqual(["1.0000 ETH", UNDECODED]);
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
expect(detail(result, "Min. paid out")?.value).toBe(
noMin ? undefined : "1990.0000 USDC",
);
});
// Uniswap's own routes: a swap paid in ETH wraps into the router what its
@@ -1278,9 +1717,18 @@ describe("any token left in the router is not received", () => {
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router",
data: execute(
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — the SETTLE spends all
// the router's ETH, the transaction's value included, so the sender pays
// an amount the calldata does not state.
test("a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router reads as a swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v4Swap(
@@ -1289,12 +1737,23 @@ describe("any token left in the router is not received", () => {
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Moved from the sender",
"Token moved",
"Moved to",
"Token Out",
"Min. received",
"Steps",
"Deadline",
]);
expect(detail(result, "Moved from the sender").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
});
});
@@ -1711,15 +2170,6 @@ describe("a payout of part of the router's output", () => {
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute(
@@ -2426,40 +2876,50 @@ 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);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
expect(detail(withFlag, "Min. received")).toBeUndefined();
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// The permit's spender is the router at ROUTER, which on every other
// router is an allowance to another spender, so the minimum reads
// Min. paid out there.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
minimum: "Min. received",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
minimum: "Min. paid out",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag",
({ address, mark, minimum }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
@@ -2473,10 +2933,25 @@ describe("the allow-revert flag", () => {
expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
// The permit names the router at ROUTER as its spender, so its
// amount stands as the Amount figure only there, and only without
// the flag. With the flag it stands on no router and gets lines
// of its own, and the swap step names Token In: the same token,
// its address written the same way.
const permitLines = [
"Permit2 allowance",
"Allowance token",
"Allowance spender",
];
const swapLines = (r) =>
r.details.filter(
(d) => !["Steps", ...permitLines].includes(d.label),
);
expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
expect(detail(withFlag, minimum).value).toBe("1.0000 WETH");
},
);
@@ -3062,6 +3537,7 @@ async function showDetail(data) {
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
+8 -1
View File
@@ -34,8 +34,14 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false,
}));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
};
const { state } = require("../src/shared/state");
@@ -208,6 +214,7 @@ async function showDetail(row, activeNetworkId) {
globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
+3
View File
@@ -90,6 +90,9 @@ function swapDetail(data, label) {
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
// The approval screen reads the explorer's report only while "Use
// Blockscout" is on.
state.useBlockscout = true;
});
describe("a swap of a token outside the bundled list", () => {
+3 -3
View File
@@ -103,7 +103,7 @@ describe("balanceLine", () => {
});
test("an unknown quantity produces no fiat figure, a zero one does", () => {
prices.NOVEL = 3;
prices[NOVEL] = 3;
const unknown = balanceLine("NOVEL", null, 3, NOVEL);
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
// A price times an unknown quantity is not $0.00: that is the same
@@ -162,7 +162,7 @@ describe("getAddressValue", () => {
// make it incomplete.
test("an unknown balance makes the total partial, a zero balance does not", () => {
prices.ETH = 2000;
prices.NOVEL = 3;
prices[NOVEL] = 3;
const unknown = getAddressValue(address(null));
const zero = getAddressValue(address("0.0"));
expect(unknown).not.toEqual(zero);
@@ -172,7 +172,7 @@ describe("getAddressValue", () => {
test("an unknown balance is not priced as zero of the token", () => {
prices.ETH = 2000;
prices.NOVEL = 3;
prices[NOVEL] = 3;
// The same row with a real quantity of 10 is worth $30. Neither that
// figure nor a confident $0.00 may be stated for the unknown one.
expect(getAddressValue(address("10.0"))).toEqual({
+8 -1
View File
@@ -72,8 +72,12 @@ global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM. Every id in index.html that these two views touch resolves to a
// fresh recording element; nothing here depends on layout, only on what the
@@ -268,6 +272,9 @@ beforeEach(() => {
state.fraudContracts = [];
state.hideLowHolderTokens = false;
state.currentView = null;
// The screens save this page's copy of "Use Blockscout" over the stored
// one. So the page's copy is turned on here too.
state.useBlockscout = true;
});
describe("the Send screen resolves the scale rather than reading the stored one", () => {