Author SHA1 Message Date
sneak f8101b559e fix: TransactionDetail reads a transaction from the Blockscout of the network it was sent on (closes #623)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
TransactionDetail read the block, network details and calldata from the
active network's Blockscout and decoded the calldata for the active network,
so after a network switch a transaction sent on the other network showed none
of them. It now reads them from the Blockscout Settings has for the network
the transaction was sent on, and decodes the calldata for that network.
reviewedNetworkEndpoints() in the shared view helpers gives that network's RPC
and Blockscout URLs, picked as reviewedNetworkProvider() picked its node;
reviewedNetworkProvider() now uses it.

Model: opus-5-5
2026-10-19 22:58:47 +00:00
85 changed files with 755 additions and 8835 deletions
+213 -622
View File
File diff suppressed because it is too large Load Diff
+46 -88
View File
@@ -57,12 +57,11 @@ 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 NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No third-party token list APIs.** The balances of the tokens you track are - **No third-party token list APIs.** Token balances come from the same block
read from your RPC endpoint. While "Use Blockscout" is on, the other tokens explorer you configure for transaction history, and the extension ships its
come from the same block explorer you configure for transaction history. The own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
extension ships its own hardcoded list of top ERC-20 contract addresses for detection. Any token you want tracked across all your addresses, you add
symbol-spoofing detection. Any token you want tracked across all your yourself by contract address.
addresses, you add yourself by contract address.
- **No backend servers operated by the developer.** Nothing is sent to any - **No backend servers operated by the developer.** Nothing is sent to any
server run by AutistMask. Every network destination is listed below. server run by AutistMask. Every network destination is listed below.
@@ -70,13 +69,8 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
## How It Works ## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. Out of the box it sends your data only to the not have a backend server. It communicates with five external destinations:
Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending three you configure yourself, and two fixed ones used for scam detection.
token symbols and nothing else.
Blockscout and Etherscan are sent none of your data until you turn on a setting
for each. The settings are "Use Blockscout" and "Check recipients on Etherscan".
Both are off in a new install. Welcome and Settings both offer them.
### External Services ### External Services
@@ -89,14 +83,11 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node. node.
When it is contacted: 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
- on every balance refresh (every 10 seconds while the popup is open, every 60 Send screen, when a send is prepared and broadcast, while a pending transaction
seconds in the background) is polled for its receipt, and for the reverse ENS lookups used to label
- when you review a send to an ENS name addresses (cached for 12 hours).
- 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, What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
@@ -105,31 +96,16 @@ addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`; **Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia) `eth-sepolia.blockscout.com/api/v2` on Sepolia)
Blockscout is an open-source blockchain explorer. AutistMask sends it your data Used to fetch token balances and transaction history. Blockscout is an
only while "Use Blockscout" is on. AutistMask then asks Blockscout for: 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.
- your recent transactions and token transfers When it is contacted: on every balance refresh, and whenever a screen showing
- the balances of the tokens you have not tracked, with the holder counts used transaction history is opened.
for spam filtering
- the block, fees and calldata of a transaction you open
You can change the endpoint in Settings to a self-hosted Blockscout instance. What gets sent: your Ethereum addresses (to look up balances and transactions).
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.
When it is contacted, while "Use Blockscout" is on:
- on every balance refresh
- whenever a screen showing transaction history is opened
- when you open a transaction
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: your Ethereum addresses (to look up balances and transactions)
and the hash of each transaction you open.
**CoinDesk CADLI price API** (`data-api.coindesk.com`) **CoinDesk CADLI price API** (`data-api.coindesk.com`)
@@ -146,21 +122,16 @@ address.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's When you review a send, AutistMask fetches the recipient's public Etherscan
public Etherscan address page when you review a send. AutistMask looks for a address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
"Fake_Phishing"/"Phish/Hack" label or a scam warning, and shows a red warning if warning, and shows a red warning if it finds one. This is a plain page fetch
it finds one. This is a plain page fetch with no API key, made by your browser. with no API key, made by your browser. It is best-effort: if it fails, it is
It is best-effort: if it fails, it is silently ignored. This endpoint is not silently ignored. This endpoint is not user-configurable.
user-configurable.
The setting is off in a new install. While it is off, the confirmation screen When it is contacted: each time you reach the send confirmation screen.
says that no Etherscan check was made.
When it is contacted: only while "Check recipients on Etherscan" is on, each What gets sent: the recipient address you are about to send to, and your IP
time you reach the send confirmation screen. address. Your own addresses are not sent.
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 Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them. contracts) are ordinary links. They contact nothing until you click them.
@@ -214,9 +185,6 @@ password.
1. Click the AutistMask icon in your browser toolbar. 1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet". 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 3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase. recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these 4. **Write down the recovery phrase and store it safely.** Anyone with these
@@ -252,14 +220,10 @@ always produce the same sequence of addresses.
### Adding ERC-20 Tokens ### Adding ERC-20 Tokens
The wallet looks for tokens you hold only while "Use Blockscout" is on. Even Tokens you hold show up automatically only if they are in the extension's
then, a token shows up only if it is in the extension's bundled list of bundled list of well-known tokens or have at least 1,000 holders; everything
well-known tokens or has at least 1,000 holders. Everything else is treated as else is treated as spam and hidden. To track a token explicitly (which also
spam and hidden. shows it at zero balance), add it by contract address:
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). 1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token". 2. Click "+ Token".
@@ -273,12 +237,9 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending ## Sending
1. Click "Send" from the home screen or an address detail view. 1. Click "Send" from the home screen or an address detail view.
2. Select what to send: ETH, or a token you track that this address holds. While 2. Select what to send (ETH, or any ERC-20 token with a balance on this address
"Use Blockscout" is on, Send also offers other tokens with a balance on this that survives the spam filters).
address that survive the spam filters. 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
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 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 decimal places, or your ETH balance minus the amount reserved for the network
fee. fee.
@@ -296,8 +257,8 @@ The confirmation screen shows:
reserve is what the network requires up front and what the balance check gates 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 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 - **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or, while "Check recipients on addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan" is on, labelled as a phisher on Etherscan Etherscan
After reviewing, enter your password and click "Sign & Send". The transaction 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. will be broadcast to the network and you will see a waiting screen with a timer.
@@ -308,9 +269,8 @@ screen with the transaction hash and an Etherscan link.
You can also send from a token-specific view: on the address detail screen, 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 click any token balance row. This opens a focused view showing only that token's
transactions. While "Use Blockscout" is off, the view says instead that no transactions. Clicking "Send" from here pre-selects and locks the token so you
transaction history is shown. Clicking "Send" from here pre-selects and locks cannot accidentally switch to a different one.
the token so you cannot accidentally switch to a different one.
## Receiving ## Receiving
@@ -384,12 +344,11 @@ balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. When the explorer reports no holder 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 count for a token, or reports something other than a whole number in plain
digits (such as "1,000"), the count is unknown. While "Use Blockscout" is off, digits (such as "1,000"), the count is unknown. An unknown count does not hide a
no holder count is known. An unknown count does not hide a token from your token from your transaction history or the send token list, and it does not get
transaction history or the send token list, and it does not get a token into a token into your balances either: such a token is listed only if it is on the
your balances either: such a token is listed only if it is on the bundled bundled known-token list or you added it yourself. The screen you reach by
known-token list or you added it yourself. The screen you reach by clicking a clicking a token balance shows a "Holders:" line only when the count is known.
token balance shows a "Holders:" line only when the count is known.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
@@ -430,9 +389,8 @@ Click the gear icon on the home screen to access settings:
that network's defaults if you have not used it before. that network's defaults if you have not used it before.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy. You can use your own node for maximum privacy.
- **Blockscout API**: the "Use Blockscout" checkbox, off until you tick it, and - **Blockscout API**: Change the Blockscout instance used for token balances and
the URL of the Blockscout instance. You can use a self-hosted instance. transaction history. 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 - **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold. transaction threshold.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions. - **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
-5
View File
@@ -13,11 +13,6 @@
"fmt": "prettier --write .", "fmt": "prettier --write .",
"fmt-check": "prettier --check ." "fmt-check": "prettier --check ."
}, },
"jest": {
"setupFiles": [
"<rootDir>/tests/support/navigatorLocks.js"
]
},
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@tailwindcss/cli": "4.3.1", "@tailwindcss/cli": "4.3.1",
+1 -20
View File
@@ -17,11 +17,7 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// the same prohibition reported early, not the guarantee. // the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state"); const { getState, updateState } = require("./state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { const { refreshBalances, getProvider } = require("../shared/balances");
refreshBalances,
dropUntrackedTokens,
getProvider,
} = require("../shared/balances");
const { debugFetch, log } = require("../shared/log"); const { debugFetch, log } = require("../shared/log");
const { const {
verifySignedTx, verifySignedTx,
@@ -1383,17 +1379,6 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW. // balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a // Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip. // wallet or address added, renamed or deleted during the round trip.
//
// Nothing is applied when the network stored NOW is not the one the refresh
// ran on. A network switch during the round trip cleared every address's
// balances (applyChainSwitchFields()), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network.
//
// 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() { async function backgroundRefresh() {
const s = await getState(); const s = await getState();
const now = Date.now(); const now = Date.now();
@@ -1417,7 +1402,6 @@ async function backgroundRefresh() {
} }
await updateState((fresh) => { await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) { for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) { for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase()); const got = refreshed.get(String(addr.address).toLowerCase());
@@ -1431,9 +1415,6 @@ async function backgroundRefresh() {
} }
} }
} }
if (!fresh.useBlockscout) {
dropUntrackedTokens(fresh.wallets, fresh.trackedTokens);
}
fresh.lastBalanceRefresh = now; fresh.lastBalanceRefresh = now;
}); });
} }
+26 -19
View File
@@ -16,20 +16,17 @@
// across any number of awaits and no concurrent work can // across any number of awaits and no concurrent work can
// move it. // move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it // updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored // back — all inside a queue, so two background writes
// record takes, so no other write lands in between, by // never interleave, and the read is one storage round trip
// the background or by any page except a page in a // ahead of the write rather than a page lifetime ahead of
// Firefox private window (README, "Stored state and its // it (which is what made the popup's saveState() need a
// version"). The read is one storage round trip ahead of
// the write rather than a page lifetime ahead of it
// (which is what made the popup's saveState() need a
// per-field merge against a baseline at all). // per-field merge against a baseline at all).
// //
// A handler that must both read and write therefore does its network work // A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState(). // against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding. // It never publishes an object other in-flight work is holding.
const { storageGet, storageSet, withLock } = require("../shared/browserApi"); const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState"); const { normalizePersisted } = require("../shared/persistedState");
const { const {
STATE_SCHEMA_VERSION, STATE_SCHEMA_VERSION,
@@ -54,6 +51,11 @@ async function getState() {
return normalizePersisted(result.autistmask); return normalizePersisted(result.autistmask);
} }
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) { async function updateStateOnce(mutate) {
const s = await getState(); const s = await getState();
await mutate(s); await mutate(s);
@@ -68,22 +70,27 @@ async function updateStateOnce(mutate) {
// Apply `mutate` to a record read fresh from storage and write the result // Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in // back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow. The write is the // place; it may be async, but it must not do anything slow — the window
// WHOLE record, which is safe only because the lock (withLock() in // between the read and the write is the window in which another context's
// src/shared/browserApi.js) is held from the read to the write: two // write is lost, and keeping it to one storage round trip is what makes a
// background turns, or a turn and a page's save, never interleave // whole-record write safe here. Concretely: the write is the WHOLE record, so
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits // a popup write that lands inside that window is reverted, in every field, by
// while it is held, except a page in a Firefox private window, which Firefox // the record this turn read before it. That is accepted because the window is
// gives locks of its own (README, "Stored state and its version"). // one round trip long and the popup is not writing while the worker is;
// widening it is what would make it a real hazard.
// //
// `mutate` must also not call updateState() itself, directly or through // `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds, // anything it awaits: the queue is strictly serial, so the inner turn waits on
// and the outer one is waiting on the inner one. That deadlocks every write of // the outer one, which is waiting on the inner one. That deadlocks the whole
// the stored record, not just the caller. Mutate the record you were handed. // background, not just the caller. Mutate the record you were handed.
// //
// Resolves with the record that was written. // Resolves with the record that was written.
function updateState(mutate) { function updateState(mutate) {
return withLock("autistmask", () => updateStateOnce(mutate)); const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
} }
module.exports = { getState, updateState }; module.exports = { getState, updateState };
+10 -119
View File
@@ -43,38 +43,6 @@
<!-- ============ WELCOME / FIRST USE ============ --> <!-- ============ WELCOME / FIRST USE ============ -->
<div id="view-welcome" class="view hidden"> <div id="view-welcome" class="view hidden">
<p class="mb-3">Welcome! To get started, add a wallet.</p> <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 <button
id="btn-welcome-add" id="btn-welcome-add"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -122,7 +90,7 @@
<div id="add-wallet-section-mnemonic"> <div id="add-wallet-section-mnemonic">
<!-- The die button shares the instruction's row: on a row <!-- The die button shares the instruction's row: on a row
of its own it pushes Import below the fold at of its own it pushes Import below the fold at
396x600. --> 360x600. -->
<div class="mb-2 flex justify-between items-start gap-2"> <div class="mb-2 flex justify-between items-start gap-2">
<p> <p>
Enter your 12 or 24 word recovery phrase below, or Enter your 12 or 24 word recovery phrase below, or
@@ -244,7 +212,7 @@
/> />
</div> </div>
<!-- The error line sits beside Import, not above it: at <!-- The error line sits beside Import, not above it: at
396x600 the button already starts near the bottom of 360x600 the button already starts near the bottom of
the popup, and a line of its own would push it below the popup, and a line of its own would push it below
the fold. The longest error fits on one line here. --> the fold. The longest error fits on one line here. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -304,10 +272,6 @@
<!-- wallet list --> <!-- wallet list -->
<div id="wallet-list"></div> <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 --> <!-- recent transactions across all addresses -->
<div> <div>
@@ -372,13 +336,6 @@
<div id="address-balances" class="min-h-[1.25rem]"> <div id="address-balances" class="min-h-[1.25rem]">
&nbsp; &nbsp;
</div> </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> </div>
<!-- actions --> <!-- actions -->
@@ -569,23 +526,12 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">What to send</label> <label class="block mb-1">What to send</label>
<!-- The token dropdown and the line under it. Both are <select
hidden when AddressToken opens Send for its own token. --> id="send-token"
<div id="send-token-dropdown"> class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
<select >
id="send-token" <option value="ETH">ETH</option>
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" </select>
>
<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 id="send-token-static" class="text-sm hidden"></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@@ -714,13 +660,6 @@
phishing/scam. Do not send funds to this address. phishing/scam. Do not send funds to this address.
</div> </div>
</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 <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]" class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
@@ -811,7 +750,6 @@
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div> <div class="text-xs text-muted mb-1">Amount</div>
<div id="wait-tx-summary" class="font-bold"></div> <div id="wait-tx-summary" class="font-bold"></div>
<div id="wait-tx-token-note"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
@@ -858,7 +796,6 @@
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div> <div class="text-xs text-muted mb-1">Amount</div>
<div id="error-tx-summary" class="font-bold"></div> <div id="error-tx-summary" class="font-bold"></div>
<div id="error-tx-token-note"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
@@ -1065,21 +1002,9 @@
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Blockscout API</h3> <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"> <p class="text-xs text-muted mb-1">
Change the URL below if you run your own Blockscout Used to fetch token balances and transaction history.
instance. Change this if you run your own Blockscout instance.
</p> </p>
<input <input
type="text" type="text"
@@ -1094,23 +1019,6 @@
</button> </button>
</div> </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"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Token Spam Protection</h3> <h3 class="font-bold mb-1">Token Spam Protection</h3>
<p class="text-xs text-muted mb-2"> <p class="text-xs text-muted mb-2">
@@ -1383,13 +1291,6 @@
> >
&nbsp; &nbsp;
</div> </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"> <p class="text-xs text-muted mb-3">
A wallet always keeps at least one address. To remove the A wallet always keeps at least one address. To remove the
last one, delete the whole wallet from Settings instead. last one, delete the whole wallet from Settings instead.
@@ -1603,16 +1504,6 @@
</div> </div>
</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 ── --> <!-- ── Decoded details ── -->
<div id="tx-detail-calldata-section" class="hidden"> <div id="tx-detail-calldata-section" class="hidden">
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
+7 -113
View File
@@ -5,14 +5,12 @@ const {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
} = require("../shared/state"); } = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log"); const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances, dropUntrackedTokens } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const { const {
$, $,
showView, showView,
@@ -24,7 +22,6 @@ const {
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const { const {
onStorageChanged,
tabsGetCurrent, tabsGetCurrent,
tabsGetZoom, tabsGetZoom,
windowsGetCurrent, windowsGetCurrent,
@@ -71,65 +68,23 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
let refreshTimer = null; let refreshTimer = null;
// The refresh 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() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
try { try {
await followStoredSettings();
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([ await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances( refreshBalances(
wallets, state.wallets,
state.rpcUrl, state.rpcUrl,
state.blockscoutUrl, state.blockscoutUrl,
state.trackedTokens, state.trackedTokens,
networkId, state.networkId,
pageClosed.signal, pageClosed.signal,
), ),
]); ]);
const refreshed = new Map(); state.lastBalanceRefresh = Date.now();
for (const wallet of wallets) { await saveState();
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(); renderWalletList();
} catch (e) { } catch (e) {
// Every call site fires this and walks away — the boot below, the ten // Every call site fires this and walks away — the boot below, the ten
@@ -161,7 +116,6 @@ const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
startRefresh, startRefresh,
restoreView,
pageClosed: pageClosed.signal, pageClosed: pageClosed.signal,
showAddWalletView: () => { showAddWalletView: () => {
pushCurrentView(); pushCurrentView();
@@ -229,47 +183,6 @@ function fallbackView() {
showView("main"); showView("main");
} }
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
//
// 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 // The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than // browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is // the window takes a vertical scrollbar out of it too. So once the screen is
@@ -307,7 +220,7 @@ async function widenApprovalWindow() {
async function init() { async function init() {
// First, before anything can save: showView() saves on every navigation // First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be // without awaiting, so a save that fails from here on has somewhere to be
// reported rather than reaching only the console // reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of // (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well. // the approval-window branch below too, since that window saves as well.
// //
@@ -414,27 +327,8 @@ async function init() {
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.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) { if (!state.hasWallet) {
welcome.show(); showView("welcome");
} else { } else {
renderWalletList(); renderWalletList();
restoreView(); restoreView();
+8 -13
View File
@@ -38,16 +38,11 @@ function markViewRendered(view) {
if (view) renderedViews.add(view); if (view) renderedViews.add(view);
} }
// Begin a fresh page-load scope. The popup gets one by being loaded. It also // Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one when every screen on the page is out of date: // asks for one when the user switches the network, because every screen
// // then on the page shows the previous network (views/settings.js); the
// - after a network switch, since those screens show the previous network // unit tests, which simulate several page loads against one module
// (showNewSettings() in views/settings.js) // instance, ask for one too.
// - 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() { function resetRenderedViews() {
renderedViews.clear(); renderedViews.clear();
} }
@@ -150,15 +145,15 @@ function isRenderablePendingTx(value) {
// The stored transaction transactionDetail.render() shows. contractAddress is // The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there. // optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open and whose // `chainId` picks the network whose explorer the links open, so when it is
// Blockscout the transaction is read from, so it has to name one. // there it has to name one.
function isRenderableTx(value) { function isRenderableTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress)) && (!value.contractAddress || isAddressText(value.contractAddress)) &&
SUPPORTED_CHAIN_IDS.has(value.chainId) (!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
); );
} }
+1 -8
View File
@@ -4,7 +4,6 @@ const {
showFlash, showFlash,
balanceLinesForAddress, balanceLinesForAddress,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
@@ -84,8 +83,6 @@ function show() {
ctx.showAddressToken(); ctx.showAddressToken();
}); });
}); });
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
$("tx-list").innerHTML = $("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>'; '<div class="text-muted text-xs py-1">Loading...</div>';
@@ -158,10 +155,6 @@ async function loadTransactions(address) {
function renderTransactions(txs) { function renderTransactions(txs) {
const list = $("tx-list"); 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) { if (txs.length === 0) {
list.innerHTML = list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>'; '<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -236,7 +229,7 @@ function init(_ctx) {
} }
$("send-to").value = ""; $("send-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-token-dropdown").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
+1 -6
View File
@@ -7,7 +7,6 @@ const {
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
@@ -240,10 +239,6 @@ async function loadTransactions(address, tokenId) {
function renderTransactions(txs) { function renderTransactions(txs) {
const list = $("address-token-tx-list"); 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) { if (txs.length === 0) {
list.innerHTML = list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>'; '<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -329,7 +324,7 @@ function init(_ctx) {
$("send-token").value = tokenId; $("send-token").value = tokenId;
} }
// Hide dropdown, show static token display // Hide dropdown, show static token display
$("send-token-dropdown").classList.add("hidden"); $("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`; let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
if (tokenId !== "ETH") { if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`; staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
+29 -47
View File
@@ -11,8 +11,6 @@ const {
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel, tokenLabel,
blockscoutOffTokenNoteHtml,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { const {
@@ -59,15 +57,9 @@ const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
// The link opens the explorer of the network with `chainId`, or of the active function approvalAddressHtml(address) {
// network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link.
function approvalAddressHtml(address, chainId) {
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
if (chainId && !networkByChainId(chainId)) { return renderAddressHtml(address, { title });
return renderAddressHtml(address, { title, noLink: true });
}
return renderAddressHtml(address, { title, chainId });
} }
// The amount line for a decoded ERC-20 call. With a known scale it is the // The amount line for a decoded ERC-20 call. With a known scale it is the
@@ -93,12 +85,13 @@ function tokenAmountText(rawAmount, decimals, symbol) {
function decodeCalldata(data, toAddress, chainId, fromAddress) { function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null; if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below: // Where a token's scale is looked for, for every decoder below: the ERC-20
// what the wallet knows of the tokens on the network with `chainId`, which // amount line and the swap's Amount and Min. received lines resolve it the
// need not be the active one. The ERC-20 amount line and the swap's Amount // same way, and refuse to format the same way when it is nowhere.
// and Min. received lines resolve the scale the same way, and refuse to const decimalsSources = {
// format the same way when it is nowhere. trackedTokens: state.trackedTokens,
const decimalsSources = tokenSourcesByChainId(chainId); wallets: state.wallets,
};
// Try ERC-20 (approve / transfer) // Try ERC-20 (approve / transfer)
try { try {
@@ -232,11 +225,10 @@ function showTxFee(approvedTx) {
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same // Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. In the native currency of the network shown // fee reads the same on both. In the native currency of the network shown
// above, and at the price on it, as the value is. // above, as the value is.
$("approve-tx-fee").textContent = formatFee( $("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas, gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId), nativeCurrencyByChainId(approvedTx.chainId),
approvedTx.chainId,
); );
let detail = let detail =
@@ -269,7 +261,10 @@ function showTxApproval(details) {
const toAddr = approvedTx.to; const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(approvedTx.value || "0");
const sources = tokenSourcesByChainId(approvedTx.chainId); const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
@@ -346,35 +341,27 @@ function showTxApproval(details) {
}; };
} }
// Every address link on this screen opens the explorer of the network the
// transaction is for, which the Network line names, whichever network is
// active.
$("approve-tx-origin").textContent = details.origin; $("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml( $("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
details.approvedFrom,
approvedTx.chainId,
);
// Show token symbol next to contract address if known on the network the // Show token symbol next to contract address if known
// transaction is for const symbol = toAddr ? tokenLabel(toAddr) : null;
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
if (toAddr) { if (toAddr) {
let toHtml = ""; let toHtml = "";
if (symbol) { if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`; toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
} }
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId); toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
} }
const ethValueFormatted = formatTxValue(ethValue); const ethValueFormatted = formatTxValue(
// Priced from the exact value, not the four-decimal figure, at the price formatEther(approvedTx.value || "0"),
// on the network the transaction is for, as the confirmation screen prices );
// its amount, so the same value reads the same on both. const ethPrice = getPrice("ETH");
const ethPrice = getPrice("ETH", approvedTx.chainId); const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const ethUsd = ethPrice ? parseFloat(ethValue) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the // In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: the active network can // Network line names, not the active network's: the active network can
@@ -401,16 +388,9 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
detailsHtml += blockscoutOffTokenNoteHtml(
d.address,
approvedTx.chainId,
);
} }
detailsHtml += approvalAddressHtml( detailsHtml += approvalAddressHtml(d.address);
d.address,
approvedTx.chainId,
);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
@@ -628,7 +608,10 @@ function permitWarningHtml(types, primaryType, domain, message) {
return ""; return "";
} }
const sources = tokenSourcesByChainId(); const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const spender = addressOrNull( const spender = addressOrNull(
declaredField(types, primaryType, message, "spender")?.value, declaredField(types, primaryType, message, "spender")?.value,
); );
@@ -657,7 +640,6 @@ function permitWarningHtml(types, primaryType, domain, message) {
html += `<div class="mb-2"><div>Token</div>`; html += `<div class="mb-2"><div>Token</div>`;
if (token) { if (token) {
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`; html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
html += blockscoutOffTokenNoteHtml(token);
html += approvalAddressHtml(token); html += approvalAddressHtml(token);
} else { } else {
html += `<div>Unknown</div>`; html += `<div>Unknown</div>`;
-5
View File
@@ -334,11 +334,6 @@ function show(txInfo) {
$("confirm-contract-warning").style.visibility = "hidden"; $("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden"; $("confirm-burn-warning").style.visibility = "hidden";
$("confirm-etherscan-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) // Show burn warning via reserved element (in addition to inline warning)
if (isBurnAddress(txInfo.to)) { if (isBurnAddress(txInfo.to)) {
-5
View File
@@ -119,11 +119,6 @@ function show(walletIdx, addrIdx) {
$("delete-address-recovery").textContent = recoveryPathText(wallet); $("delete-address-recovery").textContent = recoveryPathText(wallet);
$("delete-address-balance").innerHTML = balanceWarningHtml(addr); $("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(""); setFlash("");
showView("delete-address-confirm"); showView("delete-address-confirm");
+1 -2
View File
@@ -16,7 +16,6 @@ const {
broadcastActiveChanged, broadcastActiveChanged,
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof"); const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const welcome = require("./welcome");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null; let lostPasswordIndex = null;
@@ -136,7 +135,7 @@ async function finishDelete(walletIdx) {
// Save before broadcasting: the background reads the active // Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged. // address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged(); if (activeAddressChanged) broadcastActiveChanged();
welcome.show(); showView("welcome");
return; return;
} }
+21 -85
View File
@@ -1,7 +1,4 @@
// Helpers the views in this directory share: showing a view and going back, // Shared DOM helpers used by all views.
// error and flash lines and the warning banners, formatting of amounts, fees,
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
// of the network a chain id names, explorer links, and click-to-copy.
// //
// Escaping rule for every view in this directory, since they all build // Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string // markup by concatenation: any VALUE interpolated into an innerHTML string
@@ -21,10 +18,7 @@ const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { const { resolveTokenSymbol } = require("../../shared/approvalAmount");
resolveTokenDecimals,
resolveTokenSymbol,
} = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
@@ -303,64 +297,15 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// What the wallet knows of the tokens on the network with this chain id, or // The symbol shown for a token line, resolved from the bundled list, the
// on the active network when no chain id is given, as the `sources` that // tokens the user tracks, and the explorer's report — the same chain the
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address // amount line's scale comes from. Null when no source names one, so the token
// names a token on one network only, and every token balance held is the // lines keep saying `Unknown token` for a token nothing knows.
// active network's, so another network gets the tokens tracked on it function tokenLabel(address) {
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances. return resolveTokenSymbol(address, {
// trackedTokens: state.trackedTokens,
// The wallets' balances hold what Blockscout reported for each token. So the wallets: state.wallets,
// 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.useBlockscout ? state.wallets : [],
};
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// 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));
} }
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
@@ -571,12 +516,6 @@ function addressTitle(address, wallets) {
const CONTRACT_CREATION_TEXT = const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient."; "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 // 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 // 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 // the user sent has no counterparty (its `to` is ""), so its row has the
@@ -669,14 +608,14 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// The RPC and Blockscout URLs Settings has for the network with this chain id, // The RPC and Blockscout URLs Settings has for the network a transaction was
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail // reviewed for or sent on, the one its chain id names, which need not be the
// use the network their transaction's chain id names, and the popup can reopen // active one: the popup can reopen onto ConfirmTx, WaitTx or TransactionDetail
// onto them after Settings switched the network. The active network's are // after Settings switched the network. The active network's are state.rpcUrl
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it, // and state.blockscoutUrl, any other's the ones remembered for it, or its
// or its defaults (see applyChainSwitchFields()). // defaults (see applyChainSwitchFields()).
function endpointsByChainId(chainId) { function reviewedNetworkEndpoints(txInfo) {
const net = networkByChainId(chainId); const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) { if (net.id === state.networkId) {
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl }; return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
} }
@@ -690,7 +629,7 @@ function endpointsByChainId(chainId) {
// The node of the network a transaction was reviewed for. // The node of the network a transaction was reviewed for.
function reviewedNetworkProvider(txInfo) { function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId); const net = networkByChainId(txInfo.chainId);
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id); return getProvider(reviewedNetworkEndpoints(txInfo).rpcUrl, net.id);
} }
// Block-explorer URLs. The origin is a per-network constant from // Block-explorer URLs. The origin is a per-network constant from
@@ -833,8 +772,6 @@ module.exports = {
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenSourcesByChainId,
blockscoutOffTokenNoteHtml,
tokenLabel, tokenLabel,
formatFee, formatFee,
addressDotHtml, addressDotHtml,
@@ -843,7 +780,6 @@ module.exports = {
displaySymbol, displaySymbol,
addressTitle, addressTitle,
CONTRACT_CREATION_TEXT, CONTRACT_CREATION_TEXT,
HISTORY_OFF_TEXT,
txCounterpartyHtml, txCounterpartyHtml,
formatAddressHtml, formatAddressHtml,
renderAddressHtml, renderAddressHtml,
@@ -851,7 +787,7 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId, reviewedNetworkEndpoints,
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
+1 -8
View File
@@ -7,7 +7,6 @@ const {
timeAgo, timeAgo,
addressDotHtml, addressDotHtml,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
@@ -101,10 +100,6 @@ let homeTxs = [];
function renderHomeTxList(ctx) { function renderHomeTxList(ctx) {
const list = $("home-tx-list"); const list = $("home-tx-list");
if (!list) return; 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) { if (homeTxs.length === 0) {
list.innerHTML = list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>'; '<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -305,8 +300,6 @@ function walletListHtml() {
function render(ctx) { function render(ctx) {
const container = $("wallet-list"); 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) { if (state.wallets.length === 0) {
container.innerHTML = container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>'; '<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
@@ -454,7 +447,7 @@ function init(ctx) {
} }
$("send-to").value = ""; $("send-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-token-dropdown").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
updateSendBalance(); updateSendBalance();
+7 -12
View File
@@ -10,7 +10,6 @@ const {
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
@@ -142,8 +141,6 @@ function updateToValidation() {
} }
function renderSendTokenSelect(addr) { 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"); const sel = $("send-token");
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`; sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
const fraudSet = new Set( const fraudSet = new Set(
@@ -188,9 +185,7 @@ function renderSendTokenSelect(addr) {
// so the transfer is encoded with the number the user read rather than with // so the transfer is encoded with the number the user read rather than with
// whatever the contract answers at signing time (see // whatever the contract answers at signing time (see
// src/shared/transferAmount.js). balances.js is formatting one explorer row at // 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. As on // fetch time and cannot consult a state it is in the middle of replacing.
// 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 // 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 // `balance` is a quantity computed at a scale this screen has just declined to
@@ -203,7 +198,10 @@ function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
); );
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId()); const tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
if (!tb) return { tokenBalance: "0", tokenDecimals }; if (!tb) return { tokenBalance: "0", tokenDecimals };
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals }; if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals }; return { tokenBalance: tb.balance ?? null, tokenDecimals };
@@ -403,11 +401,8 @@ function init(_ctx) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to); 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) { if (!resolved) {
showFlash("That ENS name has no on-chain address."); showFlash("That ENS name has no address.");
return; return;
} }
resolvedTo = resolved; resolvedTo = resolved;
@@ -431,7 +426,7 @@ function init(_ctx) {
}); });
$("btn-send-back").addEventListener("click", () => { $("btn-send-back").addEventListener("click", () => {
$("send-token-dropdown").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
goBack(); goBack();
}); });
+28 -122
View File
@@ -17,7 +17,6 @@ const {
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { restorableStack } = require("../../shared/persistedState"); const { restorableStack } = require("../../shared/persistedState");
const { dropUntrackedTokens } = require("../../shared/balances");
const { resetRenderedViews, markViewRendered } = require("../viewRouter"); const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const { const {
log, log,
@@ -39,8 +38,6 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi"); const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
@@ -202,10 +199,6 @@ function show() {
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId; $("settings-network").value = state.networkId;
// 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(); renderTrackedTokens();
renderSiteLists(); renderSiteLists();
renderWalletListSettings(); renderWalletListSettings();
@@ -250,77 +243,7 @@ async function renderSiteLists() {
); );
} }
// A network switch, made by the user here or saved by the background once the function init(ctx) {
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. 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");
$("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);
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx); deleteWallet.init(ctx);
showPhrase.init(); showPhrase.init();
@@ -394,49 +317,39 @@ function init(_ctx) {
showFlash("Saved."); showFlash("Saved.");
}); });
// 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"); const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value; const newId = networkSelect.value;
cutViewStack(); if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved // Open pages are told by the background, as after a site's approved
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
showNewSettings(); resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
@@ -571,11 +484,4 @@ function init(_ctx) {
}); });
} }
module.exports = { module.exports = { init, show, renderSiteLists };
init,
show,
renderSiteLists,
cutViewStack,
setUseBlockscout,
showNewSettings,
};
+2 -10
View File
@@ -24,11 +24,7 @@
// than written again locally so that tests/popupElementIds.test.js sees these // than written again locally so that tests/popupElementIds.test.js sees these
// lookups and holds every id below against the markup. // lookups and holds every id below against the markup.
const { $, VIEWS } = require("./helpers"); const { $, VIEWS } = require("./helpers");
const { const { storageGet, storageRemove } = require("../../shared/browserApi");
storageGet,
storageRemove,
withLock,
} = require("../../shared/browserApi");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Typed in full before anything is erased, in the same spirit as the wallet // Typed in full before anything is erased, in the same spirit as the wallet
@@ -141,11 +137,7 @@ async function resetProfile() {
return; return;
} }
try { try {
// Under the lock every write of the stored record takes, so the erase await storageRemove("autistmask");
// cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask"));
} catch (e) { } catch (e) {
log.errorf("state recovery: reset failed:", e); log.errorf("state recovery: reset failed:", e);
setFlash("The saved data could not be erased. Nothing was changed."); setFlash("The saved data could not be erased. Nothing was changed.");
+7 -20
View File
@@ -18,7 +18,7 @@ const {
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId, reviewedNetworkEndpoints,
displaySymbol, displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
@@ -26,7 +26,6 @@ const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { storedSettingIsOn } = require("../../shared/storedSettings");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
/** /**
@@ -173,8 +172,7 @@ function render() {
} }
} }
// Hide the calldata and raw data sections. loadFullTxDetails() shows them // Hide calldata and raw data sections; always fetch full tx details
// once it has their data.
const calldataSection = $("tx-detail-calldata-section"); const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden"); if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section"); const rawDataSection = $("tx-detail-rawdata-section");
@@ -193,13 +191,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
// The block, network details, decoded details and raw data come from loadFullTxDetails(tx);
// 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); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -311,12 +303,8 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// Reads the block, network details and calldata from Blockscout, and decodes async function loadFullTxDetails(tx) {
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not const chainId = tx.chainId;
// 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 section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -326,13 +314,12 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return; if (!section || !actionEl || !detailsEl) return;
try { try {
const { blockscoutUrl } = endpointsByChainId(chainId); const { blockscoutUrl } = reviewedNetworkEndpoints(tx);
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/transactions/" + txHash, blockscoutUrl + "/transactions/" + tx.hash,
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
+4 -16
View File
@@ -14,7 +14,6 @@ const {
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel, tokenLabel,
blockscoutOffTokenNoteHtml,
reviewedNetworkProvider, reviewedNetworkProvider,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
@@ -109,10 +108,6 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
} }
$("wait-tx-summary").textContent = $("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : ""); 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-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId); $("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx"); attachCopyHandlers("view-wait-tx");
@@ -220,10 +215,9 @@ function showWait(txInfo, txHash) {
// init and leaving wait-tx on screen with no back control. A non-numeric // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks // 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 // 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 not checked here. // to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text. // unchecked: they are compared and coalesced rather than dereferenced, and
// Everything else only compares and coalesces them, and tokenSymbol is null for // tokenSymbol is null for ETH.
// ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -286,9 +280,8 @@ function decodedDetailsHtml(decoded, chainId) {
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`; html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
const sym = tokenLabel(d.address, chainId) || "Unknown token"; const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`; html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += blockscoutOffTokenNoteHtml(d.address, chainId);
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address, chainId);
} else { } else {
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address, chainId);
@@ -352,7 +345,6 @@ function showError(txInfo, txHash, message) {
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,
token: txInfo.token,
to: txInfo.to, to: txInfo.to,
hash: txHash || null, hash: txHash || null,
message: message, message: message,
@@ -366,10 +358,6 @@ function renderError() {
if (!d || !d.message) return; if (!d || !d.message) return;
$("error-tx-summary").textContent = $("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : ""); 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-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message; $("error-tx-message").textContent = d.message;
+2 -32
View File
@@ -1,37 +1,7 @@
// The first screen, shown while there is no wallet. It offers "Use Blockscout" const { $ } = require("./helpers");
// and "Check recipients on Etherscan", which Settings offers too. Both are off
// until the user ticks them.
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");
}
// Each change is made inside the save, holding the lock, as Settings makes it.
function init(ctx) { function init(ctx) {
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView); $("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 }; module.exports = { init };
+21 -171
View File
@@ -1,10 +1,5 @@
// Balance fetching: // Balance fetching: ETH balances via RPC, ERC-20 token balances via
// // Blockscout, ENS reverse lookup via RPC.
// - 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 { const {
JsonRpcProvider, JsonRpcProvider,
@@ -22,7 +17,6 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount"); const { resolveTokenDecimals } = require("./approvalAmount");
const { storedSettingIsOn } = require("./storedSettings");
// Use a static network to skip auto-detection (which can fail and cause // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -42,18 +36,9 @@ const { storedSettingIsOn } = require("./storedSettings");
// ethers knows chains this wallet does not, so an id that is not one of ours // 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 // is a caller bug and must not resolve to a working provider for some other
// chain. // 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) { function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id); const net = Network.from(requireNetworkId(networkId).id);
const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net }); return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
provider.disableCcipRead = true;
return provider;
} }
function requireNetworkId(networkId) { function requireNetworkId(networkId) {
@@ -95,16 +80,8 @@ function rawUnits(value) {
// Fetch token balances for a single address from Blockscout. // Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }]. // 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, // Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's // explicitly tracked by the user, or have >= 1000 holders.
// 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 // `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 // way `holders` already is. Absence is never filled in here: this is the
@@ -119,7 +96,6 @@ async function fetchTokenBalances(
trackedTokens, trackedTokens,
signal, signal,
) { ) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
try { try {
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances", blockscoutUrl + "/addresses/" + address + "/token-balances",
@@ -129,7 +105,6 @@ async function fetchTokenBalances(
return null; return null;
} }
const items = await resp.json(); const items = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return [];
if (!Array.isArray(items)) return null; if (!Array.isArray(items)) return null;
const trackedSet = new Set( const trackedSet = new Set(
@@ -230,96 +205,8 @@ async function fetchTokenBalances(
} }
} }
// 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. // 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 // `signal` is as for fetchTokenBalances().
// for fetchTokenBalances().
async function refreshBalances( async function refreshBalances(
wallets, wallets,
rpcUrl, rpcUrl,
@@ -330,8 +217,6 @@ async function refreshBalances(
) { ) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl)); log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = []; const updates = [];
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -381,43 +266,23 @@ async function refreshBalances(
}), }),
); );
// ERC-20 token balances: the tracked tokens' through the RPC // ERC-20 token balances via Blockscout
// 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( updates.push(
Promise.all([ fetchTokenBalances(
fetchTrackedTokenBalances( addr.address,
provider, blockscoutUrl,
addr.address, trackedTokens,
tracked, signal,
previous, ).then((balances) => {
explorerRequest, if (balances !== null) {
signal, addr.tokenBalances = balances;
), log.debugf(
explorerRequest, "Token balances",
]).then(([trackedRows, explorerRows]) => { addr.address,
const untrackedRows = (explorerRows ?? previous).filter( balances.length,
(row) => "tokens",
!trackedSet.has(String(row.address).toLowerCase()), );
); }
addr.tokenBalances = trackedRows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
addr.tokenBalances.length,
"tokens",
);
}), }),
); );
} }
@@ -427,20 +292,6 @@ async function refreshBalances(
log.debugf("refreshBalances done"); 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. // Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20. // Calls symbol() and decimals() to verify it implements ERC-20.
// `signal` is as for fetchTokenBalances(). // `signal` is as for fetchTokenBalances().
@@ -559,7 +410,6 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
module.exports = { module.exports = {
fetchTokenBalances, fetchTokenBalances,
refreshBalances, refreshBalances,
dropUntrackedTokens,
lookupTokenInfo, lookupTokenInfo,
getProvider, getProvider,
scanForAddresses, scanForAddresses,
-47
View File
@@ -31,11 +31,6 @@
// Everything is resolved on use rather than captured at module load. The MV3 // Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit // service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require(). // suite installs its stubs on `global.chrome` around a require().
//
// The module also holds withLock(), a wrapper around the browser's
// `navigator.locks`, which is in neither namespace. It sits beside
// storageGet() and storageSet() because every write of the stored record
// takes it.
// The extension API namespace, preferring `browser.*` where it exists. // The extension API namespace, preferring `browser.*` where it exists.
// //
@@ -224,46 +219,6 @@ function storageRemove(keys) {
return Promise.resolve(storage.remove(keys)); return Promise.resolve(storage.remove(keys));
} }
/**
* Call `listener` after every write to `storage.local`, whether this page,
* another page or the background made it, with what the write changed, as the
* browser reports it: `{ [key]: { oldValue, newValue } }`.
*
* @param {function(Object): void} listener
*/
function onStorageChanged(listener) {
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
if (areaName === "local") listener(changes);
});
}
/**
* Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
* and its background share these locks, except a page in a Firefox private
* window (README, "Stored state and its version"), so while one of them holds
* `name` another asking for it waits, in the order they asked. The lock is let
* go when `fn` settles, whether it resolved or rejected.
*
* Every write of the stored record holds the lock named after its key,
* "autistmask", from its read to its write: saveState() in
* src/shared/state.js, updateState() in src/background/state.js, and the
* reset in src/popup/views/stateRecovery.js.
*
* @param {string} name
* @param {function(): Promise<*>} fn
* @returns {Promise<*>} what `fn` resolved with.
* @throws rejects, without running `fn`, where `navigator.locks` is absent.
*/
function withLock(name, fn) {
if (typeof navigator === "undefined" || !navigator.locks) {
return Promise.reject(
new Error("navigator.locks is not available in this context"),
);
}
return navigator.locks.request(name, () => fn());
}
// `storage.session`, the browser's session storage: what is kept there lasts // `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped // until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background // and started again, which module-level variables do not (README, Background
@@ -398,7 +353,6 @@ module.exports = {
getPlatformInfo, getPlatformInfo,
hasBrowserNamespace, hasBrowserNamespace,
notify, notify,
onStorageChanged,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
sessionStorageGet, sessionStorageGet,
@@ -419,5 +373,4 @@ module.exports = {
windowsGetLastFocused, windowsGetLastFocused,
windowsRemove, windowsRemove,
windowsUpdate, windowsUpdate,
withLock,
}; };
+17 -37
View File
@@ -1,18 +1,8 @@
// The popup's network switch. // Consolidated chain-switch handler for the popup.
// //
// A network switch reaches the popup's `state` in one of two ways: // Every state change required when the active network changes is
// // performed here so that callers (settings UI, future chain additions) all go
// - onChainSwitch() below makes the switch the user picks in Settings, with // through a single code path.
// applyChainSwitchFields() (chainSwitchFields.js).
// - 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 // Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed. // networks.js — no per-caller wiring is needed.
@@ -30,32 +20,22 @@
const { applyChainSwitchFields } = require("./chainSwitchFields"); const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices"); const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state. // Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain, or null when // Returns the network configuration object for the new chain.
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
let net = null;
await saveState(() => { const net = applyChainSwitchFields(state, newNetworkId);
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); // --- price cache ---
clearPopupCaches(); // Prices are chain-specific (testnet tokens are worthless,
}); // ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
return net; return net;
} }
module.exports = { onChainSwitch, clearPopupCaches }; module.exports = { onChainSwitch };
+2 -31
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of // the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js). // its own read-modify-write (src/background/state.js).
// //
// Everything here is synchronous and touches nothing but the objects it is // Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is // given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage // what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back. // microseconds earlier and is about to be written back.
@@ -76,33 +76,4 @@ function applyChainSwitchFields(s, newNetworkId) {
return net; return net;
} }
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it: module.exports = { applyChainSwitchFields };
// the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in
// src/shared/state.js). Balances are taken by address, and an address
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens;
s.trackedTokens = switched.trackedTokens;
s.networkId = switched.networkId;
s.rpcUrl = switched.rpcUrl;
s.blockscoutUrl = switched.blockscoutUrl;
s.lastBalanceRefresh = switched.lastBalanceRefresh;
s.tokenHolderCache = switched.tokenHolderCache;
s.fraudContracts = switched.fraudContracts;
const stored = new Map();
for (const wallet of switched.wallets) {
for (const addr of wallet.addresses) stored.set(addr.address, addr);
}
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
}
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
-11
View File
@@ -3,7 +3,6 @@
// This is a best-effort enrichment: network failures return null silently. // This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks"); const { networkByChainId } = require("./networks");
const { storedSettingIsOn } = require("./storedSettings");
// Patterns in the page title that indicate a flagged address. // Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan" // Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
@@ -79,19 +78,12 @@ function parseEtherscanPage(html) {
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on * when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia. * 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} address - Ethereum address to check.
* @param {string} [chainId] - Chain id of the network to look it up on. * @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>} * @returns {Promise<{type: string, message: string, severity: string}|null>}
*/ */
async function checkEtherscanLabel(address, chainId) { async function checkEtherscanLabel(address, chainId) {
try { try {
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
return null;
}
// Lazy require to avoid pulling in chrome.storage at module scope // Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage). // (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
@@ -102,9 +94,6 @@ async function checkEtherscanLabel(address, chainId) {
}); });
if (!resp.ok) return null; if (!resp.ok) return null;
const html = await resp.text(); const html = await resp.text();
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
return null;
}
const result = parseEtherscanPage(html); const result = parseEtherscanPage(html);
if (result.isPhishing) { if (result.isPhishing) {
return { return {
-12
View File
@@ -48,14 +48,6 @@ const DEFAULT_STATE = {
hideDustTransactions: true, hideDustTransactions: true,
dustThresholdGwei: 100000, dustThresholdGwei: 100000,
utcTimestamps: false, 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,
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system", theme: "system",
@@ -414,10 +406,6 @@ function normalizePersisted(saved) {
: 100000; : 100000;
out.utcTimestamps = out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; 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;
// A list of contract addresses, floored the same way: send.js builds its // A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())` // fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(), // and filterTransactions() maps the same list through normalizeAddress(),
+2 -3
View File
@@ -38,12 +38,11 @@ function clearPrices() {
} }
// Return the USD price for a symbol on the network with `chainId`, or on the // Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet, on a chain id // active network when no chain id is given; null on testnet / unknown.
// that names no network, or for a symbol with no price.
function getPrice(symbol, chainId) { function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork(); const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (!network || network.isTestnet) return null; if (network.isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
+37 -58
View File
@@ -23,7 +23,6 @@
// guarantee. // guarantee.
const { networkById } = require("./networks"); const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const { const {
DEFAULT_STATE, DEFAULT_STATE,
PERSISTED_FIELDS, PERSISTED_FIELDS,
@@ -36,7 +35,7 @@ const {
migrationNeeded, migrationNeeded,
} = require("./stateSchema"); } = require("./stateSchema");
const { storageGet, storageSet, withLock } = require("./browserApi"); const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log"); const { log } = require("./log");
// The live record the proxy below guards. Everything inside this module reads // The live record the proxy below guards. Everything inside this module reads
@@ -126,10 +125,8 @@ function currentNetwork() {
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network // finished, or when its last successful saveState() began. saveState() diffs
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // the live state against this to find only the fields THIS page changed since.
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -461,20 +458,20 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// This page's own live `state` is deliberately NOT rehydrated from a field // This page's own live `state` is deliberately NOT rehydrated from a field
// another page changed — only the record written to storage is merged. // another page changed — only the record written to storage is merged.
// showView() fires saveState() on every navigation without awaiting it, // showView() fires saveState() on every navigation without awaiting it,
// which is why saves from one page wait for each other (see saveState()), and // which is what makes the queue above necessary in the first place, and a
// a save that is slow to come back has no way to tell whether the field it // save that is slow to come back has no way to tell whether the field it
// is about to hand back is still the current answer or has since been // is about to hand back is still the current answer or has since been
// overtaken by something this very page did in the meantime; writing it // overtaken by something this very page did in the meantime; writing it
// into `state` regardless reintroduced exactly the clobber this function // into `state` regardless reintroduced exactly the clobber this function
// exists to remove, just delayed and confined to one page instead of two // exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it // (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. Three // before this fix; only the persisted record is guaranteed current.
// fields are the exceptions: the network, "Use Blockscout" and "Check async function saveStateOnce() {
// recipients on Etherscan". Through `beforeWrite`, the popup takes a change to // A copy, so what this save compares and writes is the page's state as it
// any of them that another context saved (followStoredSettings() in // stood when the save began. A change made while it waits on storage is
// src/popup/index.js). // left for the next save, which compares against this copy.
async function saveStateOnce(beforeWrite) { const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -488,12 +485,6 @@ async function saveStateOnce(beforeWrite) {
// come from storage in its loaded (self-healed) shape. See // come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js. // normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask); const fresh = normalizePersisted(result.autistmask);
if (beforeWrite && beforeWrite(fresh) === false) return;
// A copy, so what this save compares and writes is the page's state as it
// stood once the stored record was read. A change made while it waits on
// the write is left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const merged = { ...fresh }; const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) { for (const key of PERSISTED_FIELDS) {
@@ -538,21 +529,35 @@ async function saveStateOnce(beforeWrite) {
rawState.hasWallet = rawState.wallets.length > 0; rawState.hasWallet = rawState.wallets.length > 0;
// What this save compared and wrote, not the page's state now: a change // What this save compared and wrote, not the page's state now: a change
// made during the save must still differ from the baseline, or the next // made during the save must still differ from the baseline, or the save
// save finds nothing to store // queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448). // (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current; baseline = current;
} }
// showView() calls saveState() on every navigation without awaiting it, so
// two saves from the SAME page can be in flight at once — e.g. a screen
// shown, then immediately replaced before the first save's storageGet()
// round trip has come back. Left concurrent, the first save's turn would
// finish after the second's live-state mutation and then re-hydrate `state`
// from what IT read, stomping the second, later change back to a stale
// value — the same clobber this function exists to prevent, just between
// two saves on one page instead of two pages. Queuing makes every save's
// snapshot-diff-write-rehydrate run start to finish before the next one
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen // Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js). // to say it on (src/popup/index.js).
// //
// A save that fails must not fail silently. showView() fires saveState() on // A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and `await saveState()` inside an // every navigation without awaiting it, and the queue below has to attach a
// unguarded event handler is no better: either way, without this, a failure // rejection handler to keep advancing — so a failing save was swallowed
// would reach only the console, where the user never looks, and the wallet // entirely: no throw, no message, nothing on screen. The wallet kept running
// would keep running against storage that rejects every write. That is the // against storage that was rejecting every write, which is the data-loss half
// data-loss half of https://git.eeqj.de/sneak/AutistMask/issues/362. // of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
// //
// This is the "tell the user" half; the other half is the floor in // This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in // normalizePersisted(), which stops the malformed-record cause from arising in
@@ -578,25 +583,11 @@ function reportSaveFailure(err) {
} }
} }
// Each save runs start to finish holding the lock every write of the stored function saveState() {
// record takes (withLock() in src/shared/browserApi.js), so no write by const turn = saveQueue.then(saveStateOnce);
// another page or the background lands between its read and its write and is // The queue must advance even when a save rejects, or every save after
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox // it queues behind a promise that never settles.
// private window is the exception: Firefox gives it locks of its own (README, saveQueue = turn.catch(() => {});
// "Stored state and its version").
//
// The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be
// asked for at once; they take the lock in the order they were asked, and each
// compares the page's state, as it stands once the stored record has been read
// and `beforeWrite` has run, against the baseline the save before it left.
//
// `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is
// written; any other return lets the save go ahead.
function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this // Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await // one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling. // keeps its own error handling.
@@ -604,17 +595,6 @@ function saveState(beforeWrite) {
return turn; return turn;
} }
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (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 // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -660,7 +640,6 @@ module.exports = {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
-27
View File
@@ -1,27 +0,0 @@
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
// 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.
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;
}
}
module.exports = { storedSettingIsOn };
+3 -17
View File
@@ -20,8 +20,6 @@ const { toDecimals } = require("./transferAmount");
// truncation without the approval screens' nonzero floor: the transaction // truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision. // detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay"); const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap");
const { storedSettingIsOn } = require("./storedSettings");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // over the address, not part of its identity. Every address comparison in
@@ -53,6 +51,7 @@ function parseTx(tx, addrLower, chainId) {
} }
// Map known DEX methods to "Swap" for cleaner display // Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([ const SWAP_METHODS = new Set([
"execute",
"swap", "swap",
"swapExactTokensForTokens", "swapExactTokensForTokens",
"swapTokensForExactTokens", "swapTokensForExactTokens",
@@ -60,16 +59,9 @@ function parseTx(tx, addrLower, chainId) {
"swapTokensForExactETH", "swapTokensForExactETH",
"swapExactTokensForETH", "swapExactTokensForETH",
"swapETHForExactTokens", "swapETHForExactTokens",
"multicall",
]); ]);
// Multisigs, smart accounts and many other contracts also have an const label = SWAP_METHODS.has(method)
// `execute` or `multicall` method, so either is a swap only when sent
// to a Universal Router on this transaction's network: the same
// addresses the approval screen decodes a swap for.
const isSwap =
SWAP_METHODS.has(method) ||
((method === "execute" || method === "multicall") &&
routerRelease(to, chainId) !== null);
const label = isSwap
? "Swap" ? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1); : method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract"; direction = "contract";
@@ -236,17 +228,12 @@ function mergeTransactions(txs, tokenTransfers) {
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry // `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
// carries it, and a native entry is labelled with that network's // carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`). // `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( async function fetchRecentTransactions(
address, address,
blockscoutUrl, blockscoutUrl,
chainId, chainId,
count = 25, count = 25,
) { ) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address); const addrLower = normalizeAddress(address);
@@ -277,7 +264,6 @@ async function fetchRecentTransactions(
const txJson = txResp.ok ? await txResp.json() : {}; const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
if (!(await storedSettingIsOn("useBlockscout"))) return [];
const txs = mergeTransactions( const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)), (txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
+132 -737
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -41,8 +41,6 @@ jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => { refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now()); mockBalanceRefreshAt.push(Date.now());
}), }),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
getProvider: jest.fn(() => ({})), getProvider: jest.fn(() => ({})),
})); }));
-3
View File
@@ -84,9 +84,6 @@ function walletsHolding(token, decimals) {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; state.wallets = [];
// The approval screen reads the explorer's report only while "Use
// Blockscout" is on.
state.useBlockscout = true;
}); });
describe("resolveTokenDecimals", () => { describe("resolveTokenDecimals", () => {
-270
View File
@@ -1,270 +0,0 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active.
//
// The screen named the token, and scaled its amount, from the active network's
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
// an unknown token with its amount in base units, and the screens after it
// labelled the amount `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
// It now uses what the wallet knows of the tokens on the network the
// transaction is for.
//
// Its address links opened the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
// explorer of the network the transaction is for.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Ethereum Mainnet active and FROM holding
// `tokenBalances` there, for the site's transaction sending TRANSFER_DATA to
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
// the signed transaction with a failure, which leads to the error screen.
async function showTokenTransfer(
tokenBalances = [],
chainId = NETWORKS.sepolia.chainId,
) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({
error: "The transaction could not be sent.",
retryable: false,
});
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: TOKEN,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "mainnet";
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances },
],
},
];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
}
async function confirm() {
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
beforeEach(() => {
state.trackedTokens = [];
state.networkTrackedTokens = {};
});
describe("a token tracked on Sepolia only", () => {
beforeEach(() => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
});
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showTokenTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
test("labels the amount on the error screen that follows", async () => {
await showTokenTransfer();
await confirm();
expect(state.currentView).toBe("error-tx");
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
});
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
await showTokenTransfer([
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// The From and Contract lines, and the decoded Token and Recipient lines.
test("every address links to the Sepolia explorer", async () => {
await showTokenTransfer();
const sepolia = NETWORKS.sepolia.explorerUrl;
expect(node("approve-tx-from").innerHTML).toContain(
`href="${sepolia}/address/${FROM}"`,
);
expect(node("approve-tx-to").innerHTML).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Token")).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Recipient")).toContain(
`href="${sepolia}/address/${RECIPIENT}"`,
);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
}
});
// Chain id 1337 names no network here, so it has no explorer to link to.
test("a transaction on a chain id that names no network is shown with no address links", async () => {
await showTokenTransfer([], "0x539");
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-network").textContent).toBe(
"Unknown network (chain id 1337)",
);
expect(node("approve-tx-from").innerHTML).toContain(FROM);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain("href=");
}
});
-308
View File
@@ -1,308 +0,0 @@
// 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);
});
-192
View File
@@ -1,192 +0,0 @@
// The USD value on the approval screen's Value and Network fee lines, for a
// site's ETH send.
//
// It was worked out from the four-decimal figure the line shows, at the price
// on the active network, so the same value read a few cents different here than
// on the confirmation screen, which prices the exact amount at the price on the
// transaction's own network, and a transaction on another network than the
// active one was priced at the active one's rate, its fee too
// (https://git.eeqj.de/sneak/AutistMask/issues/614).
//
// Driven through the real approval screen, against a minimal DOM stub in the
// shape tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { prices, clearPrices } = require("../src/shared/prices");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the approval screen, with the network `networkId` active, for the
// site's transaction sending 1.23414 ETH to RECIPIENT on the chain `chainId`.
async function showEthSend(chainId, networkId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "1234140000000000000",
data: "0x",
accessList: [],
},
});
},
};
state.networkId = networkId;
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [{ address: FROM, index: 0, balance: "0" }],
},
];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.currentView = null;
approval.init({});
await approval.show(1);
}
beforeEach(() => {
prices.ETH = 2000;
});
afterEach(() => {
clearPrices();
});
// 1.23414 ETH reads 1.2341 ETH. At $2,000 the exact value is $2,468.28, the
// figure the confirmation screen shows for it; the shown figure would be
// $2,468.20.
test.each([
[
"a mainnet transaction is priced from its exact value",
NETWORKS.mainnet.chainId,
"mainnet",
"1.2341 ETH ($2,468.28)",
],
[
"a mainnet transaction shown with Sepolia active is priced at the mainnet price",
NETWORKS.mainnet.chainId,
"sepolia",
"1.2341 ETH ($2,468.28)",
],
[
"a Sepolia transaction shown with mainnet active has no USD value",
NETWORKS.sepolia.chainId,
"mainnet",
"1.2341 SepoliaETH",
],
// Chain id 1337 names no network here, so nothing knows its price.
[
"a transaction on a chain id that names no network has no USD value",
"0x539",
"mainnet",
"1.2341 ETH",
],
])("%s", async (_label, chainId, networkId, expected) => {
await showEthSend(chainId, networkId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-value").textContent).toBe(expected);
});
// The Network fee line is priced on the transaction's network too. 21000 gas at
// up to 2 gwei is 0.000042 ETH, $0.08 at $2,000.
test.each([
[
"the fee of a mainnet transaction shown with Sepolia active is priced at the mainnet price",
NETWORKS.mainnet.chainId,
"sepolia",
"0.00004 ETH ($0.08)",
],
[
"the fee of a Sepolia transaction shown with mainnet active has no USD value",
NETWORKS.sepolia.chainId,
"mainnet",
"0.00004 SepoliaETH",
],
])("%s", async (_label, chainId, networkId, expected) => {
await showEthSend(chainId, networkId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-fee").textContent).toBe(expected);
});
+1 -8
View File
@@ -154,14 +154,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => { test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], { reopenedOn("settings", ["main", "transaction"], {
viewData: { viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
tx: {
hash: "0xdead",
from: ADDRESS,
to: ADDRESS,
chainId: "0x1",
},
},
}); });
goBack(); goBack();
expect(calls).toEqual(["transactionDetail"]); expect(calls).toEqual(["transactionDetail"]);
+1 -102
View File
@@ -19,10 +19,7 @@
// that followed persisted the pre-refresh values — while still stamping // that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo. // lastBalanceRefresh, suppressing the redo.
// //
// A network switch landing inside a balance refresh's round trip is covered at // Both use the real persistence path over a cloning storage stub. Nothing here
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
//
// All use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it // asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right. // holds against any implementation that gets the outcome right.
@@ -142,8 +139,6 @@ function loadWorker(networkId, opts) {
}, },
refreshBalances: refreshBalances:
options.refreshBalances || jest.fn(async () => undefined), options.refreshBalances || jest.fn(async () => undefined),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false, isPhishingDomain: () => false,
@@ -400,99 +395,3 @@ describe("a balance refresh under another handler's state read", () => {
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE); expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
}); });
}); });
describe("a balance refresh under a network switch", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
// address's balances, and a refresh that started on the previous network
// must not write that network's back over them when it returns. The same
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// The user switches to Ethereum Mainnet in Settings while the round
// trip is out.
const during = bg.persisted();
applyChainSwitchFields(during, MAINNET.id);
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
expect(ranOn).toEqual(["sepolia", "mainnet"]);
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});
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([]);
});
});
+4 -16
View File
@@ -18,17 +18,6 @@ const {
const RPC_URL = "https://rpc.example.invalid"; const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2"; const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const ADDRESS = "0x1111111111111111111111111111111111111111"; 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; const realFetch = globalThis.fetch;
let logged; let logged;
@@ -41,9 +30,9 @@ beforeEach(() => {
const failedToFetch = async () => { const failedToFetch = async () => {
throw new TypeError("Failed to fetch"); throw new TypeError("Failed to fetch");
}; };
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and // The RPC calls (ETH balance, ENS name, and the scan's balance and
// the scan's balance and transaction count) and the explorer request (the // transaction count) and the explorer request (token balances) all fail
// other token balances) all fail the same way. // the same way.
FetchRequest.registerGetUrl(failedToFetch); FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch); globalThis.fetch = jest.fn(failedToFetch);
}); });
@@ -60,7 +49,7 @@ function refresh(signal) {
wallets, wallets,
RPC_URL, RPC_URL,
EXPLORER_URL, EXPLORER_URL,
[TRACKED], [],
"mainnet", "mainnet",
signal, signal,
); );
@@ -71,7 +60,6 @@ test("a failure while the popup is open is reported", async () => {
for (const label of [ for (const label of [
"ETH balance failed", "ETH balance failed",
"ENS reverse failed", "ENS reverse failed",
"token balance failed",
"fetchTokenBalances failed: Failed to fetch", "fetchTokenBalances failed: Failed to fetch",
]) { ]) {
expect(logged.some((line) => line.includes(label))).toBe(true); expect(logged.some((line) => line.includes(label))).toBe(true);
-161
View File
@@ -1,161 +0,0 @@
// A balance refresh the popup started on one network, and that returns after
// the network was switched to another, writes nothing: the switch cleared every
// address's balances, and the previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
// by the user in this popup's Settings, or by the background after the user
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
afterEach(() => {
cleanupPopup();
});
// Starts the ten-second refresh and holds it open. The balance stand-in writes
// Ethereum Mainnet's balances in place, on the records handed in, as the real
// refreshBalances() does, once the returned function is called. Resolves with
// that function and the refresh still running.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
});
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
// recorded as done.
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The switch is made only once the refresh's save, holding the lock, has
// written Ethereum Mainnet's balances, and the switch then clears them.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
// The next read of the stored record, the refresh's save's, is held.
const get = page.storage.local.get;
const read = get.getMockImplementation();
let releaseRead;
get.mockImplementationOnce(async (key) => {
await new Promise((resolve) => {
releaseRead = resolve;
});
return read(key);
});
const finishing = refresh.finish();
await page.settle();
// The user picks Sepolia meanwhile; the switch's own save waits for the
// refresh's save to let the lock go.
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
releaseRead();
await finishing;
await switching;
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
test("a refresh that returns after the background switched the network writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { saveState } = require("../src/shared/state");
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told of it
// yet.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
await refresh.finish();
expectNothingStored(page);
// Nor does a later save by this page, such as every navigation makes.
await saveState();
expectNothingStored(page);
});
-848
View File
@@ -1,848 +0,0 @@
// 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);
});
});
+1 -4
View File
@@ -446,10 +446,7 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => { global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP); bg.send(msg, POPUP);
}; };
const { state, loadState } = require("../src/shared/state"); await require("../src/shared/state").loadState();
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({ require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal, pageClosed: new AbortController().signal,
}); });
-2
View File
@@ -73,8 +73,6 @@ function loadColdWorker(networkId, opts) {
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}), getProvider: () => ({}),
refreshBalances: options.refreshBalances || jest.fn(async () => {}), refreshBalances: options.refreshBalances || jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false, isPhishingDomain: () => false,
+2 -9
View File
@@ -108,10 +108,7 @@ global.fetch = jest.fn(() => {
}); });
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element. // A stub DOM: every id resolves to a recording element.
const elements = new Map(); const elements = new Map();
@@ -161,7 +158,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { const {
Interface, Interface,
@@ -246,10 +243,6 @@ async function openSend(token, tokenUnits = null) {
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 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( await refreshBalances(
state.wallets, state.wallets,
"https://rpc.example.invalid", "https://rpc.example.invalid",
+1 -7
View File
@@ -50,14 +50,8 @@ function explorerPage(title) {
// The explorer labels no address. // The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan")); global.fetch = jest.fn(async () => explorerPage("Etherscan"));
// The Etherscan label check is made, so its box can be shown.
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element. // A stub DOM: every id resolves to a recording element.
const elements = new Map(); const elements = new Map();
+3 -9
View File
@@ -142,19 +142,13 @@ jest.mock("../src/popup/views/txStatus", () => ({
})); }));
// The Etherscan label check is its only caller here: the explorer pages it // The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched. The stored profile has the // asks for are recorded, and nothing is fetched.
// check turned on.
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element. // A stub DOM: every id resolves to a recording element.
const elements = new Map(); const elements = new Map();
@@ -195,7 +189,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { const {
BaseWallet, BaseWallet,
-2
View File
@@ -297,8 +297,6 @@ describe.each([
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
state.selectedToken = "ETH"; 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 () => { test("a contract creation's row says so, with no colour dot and no address line", async () => {
+1 -1
View File
@@ -101,7 +101,7 @@ describe("the flash line the message is shown in", () => {
// //
// The line cuts a message too long for it with an ellipsis (see // The line cuts a message too long for it with an ellipsis (see
// showFlash() in src/popup/views/helpers.js). The assertions that // showFlash() in src/popup/views/helpers.js). The assertions that
// measure that, in a real browser at the documented 396x600 popup, are // measure that, in a real browser at the documented 360x600 popup, are
// "a rejected dust threshold shifts no layout (#233)" and "an over-long // "a rejected dust threshold shifts no layout (#233)" and "an over-long
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by // flash message keeps to one line (#252)" in tests/e2e/run.js, run by
// make test-e2e. They are not in make check because REPO_POLICIES.md // make test-e2e. They are not in make check because REPO_POLICIES.md
-12
View File
@@ -867,14 +867,6 @@ step(
"the page was not told of the approved switch: " + "the page was not told of the approved switch: " +
JSON.stringify(events), JSON.stringify(events),
); );
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await d.switchToWindow(env.popupWindow);
await d.waitFor(
"the open popup to show the testnet banner",
`const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";`,
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet); await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network"); await d.click("#btn-approve-network");
@@ -889,10 +881,6 @@ step(
isDeepStrictEqual(await storedNetwork(env), before), isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints", "switching back did not restore the mainnet network and endpoints",
); );
await d.waitFor(
"the open popup to drop the testnet banner",
`return document.getElementById("debug-banner") === null;`,
);
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
}, },
); );
+2 -23
View File
@@ -653,12 +653,6 @@ function traceEnabled(raw) {
* confirmed receipt instead of null, so a wait screen resolves. * confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt * @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time. * 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) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
@@ -697,13 +691,6 @@ async function installNetworkStubs(ctx, opts) {
const origin = fromWorker ? "[sw] " : "[page] "; const origin = fromWorker ? "[sw] " : "[page] ";
console.log("# routed " + origin + req.method() + " " + req.url()); 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. // JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") { if (req.method() === "POST") {
@@ -775,20 +762,12 @@ async function installNetworkStubs(ctx, opts) {
return route.fulfill({ status: 204, body: "" }); return route.fulfill({ status: 204, body: "" });
} }
// Best-effort Etherscan address labels: served as an empty page, or // Best-effort Etherscan address labels: served as an empty page.
// as a page labelling the address with opts.etherscanLabel.
if (url.hostname.endsWith("etherscan.io")) { if (url.hostname.endsWith("etherscan.io")) {
const title = opts.etherscanLabel
? "<title>" +
opts.etherscanLabel +
" | Address: " +
p.split("/").pop() +
" | Etherscan</title>"
: "";
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "text/html", contentType: "text/html",
body: "<html><head>" + title + "</head><body></body></html>", body: "<html><body></body></html>",
}); });
} }
+11 -384
View File
@@ -60,14 +60,6 @@ const TEST_TIMEOUT_MS = 120000;
// trailing drain in main(). // trailing drain in main().
const TRAILING_WATCH_MS = 1500; const TRAILING_WATCH_MS = 1500;
// The popup's size, as README gives it: `body` in src/popup/styles/main.css
// is 396px wide, and src/background/index.js opens an approval window at
// the same 396x600. Pages in this context otherwise get Playwright's
// 1280x720 default, where the page never scrolls sideways and a button
// below the popup's fold is still on screen, so a layout measured there
// passes whatever the page does.
const POPUP_VIEWPORT = { width: 396, height: 600 };
const tests = []; const tests = [];
function test(name, fn) { function test(name, fn) {
@@ -269,69 +261,6 @@ 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 // 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, // 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 // the assertion for that is the harness's own: a console.error from the page
@@ -359,261 +288,6 @@ test("wallet creation through the UI reaches the main view, and reloading mid-sc
assert(addrCount > 0, "no addresses rendered in the wallet list"); 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) => { test("add token screen opens from address detail (#150)", async (env) => {
await openAddressDetail(env.page); await openAddressDetail(env.page);
await env.page.click("#btn-add-token"); await env.page.click("#btn-add-token");
@@ -685,14 +359,11 @@ async function leaveTransactionDetail(page) {
await openAddressDetail(page); await openAddressDetail(page);
} }
// Back out to Home from wherever the previous test finished, and wait for the // Back out to Home from wherever the previous test finished.
// save that navigation makes, so a test reading the stored stack next reads
// the one Home left.
async function goHome(page) { async function goHome(page) {
await leaveTransactionDetail(page); await leaveTransactionDetail(page);
await page.click("#btn-address-back"); await page.click("#btn-address-back");
await visible(page, "#view-main"); await visible(page, "#view-main");
await waitForPersisted(page, "currentView", "main", "back on Home");
} }
// One field of the popup's state as it was actually persisted, read out of // One field of the popup's state as it was actually persisted, read out of
@@ -2214,6 +1885,12 @@ test("a network switch in Settings shows and removes the testnet banner at once
// ------------------------------------------------ dust threshold (#233) // ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
// context otherwise get Playwright's 1280x720 default, at which the flash
// line has room for any plausible message and never wraps — measuring
// there would pass for every string and prove nothing.
const POPUP_VIEWPORT = { width: 360, height: 600 };
// Everything below the flash line that must not move when it fills, plus // Everything below the flash line that must not move when it fills, plus
// the height of the line itself. Runs in the page. // the height of the line itself. Runs in the page.
// //
@@ -2462,7 +2139,7 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
// ------------------------------------------ add wallet Import button (#493) // ------------------------------------------ add wallet Import button (#493)
// At 396x600 the add wallet screen's Import button already starts near the // At 360x600 the add wallet screen's Import button already starts near the
// bottom of the popup, so its password error line sits beside the button // bottom of the popup, so its password error line sits beside the button
// rather than above it. Measures the button and the line empty and again // rather than above it. Measures the button and the line empty and again
// filled with the longest error addWallet.js puts there. Runs in the page. // filled with the longest error addWallet.js puts there. Runs in the page.
@@ -3754,14 +3431,10 @@ test("six failed receipt lookups in a row end on the unreachable-network message
// timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as // timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect. // anything that names the defect.
// The issue's reproduction, with the frame sized to the popup. // Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL = const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' + '<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:' + 'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
POPUP_VIEWPORT.width +
"px;height:" +
POPUP_VIEWPORT.height +
'px;z-index:99999"></iframe>';
// What a correctly escaped and capped render of it reads as: the first // What a correctly escaped and capped render of it reads as: the first
// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than // MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than
@@ -3798,24 +3471,6 @@ function hostileSymbolState(page, tokenAddress) {
} }
test("a token whose symbol() returns markup renders as text (#307)", async (env) => { 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.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true; env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL; env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
@@ -5201,12 +4856,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " + "the page was not told of the approved switch: " +
JSON.stringify(events), JSON.stringify(events),
); );
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await env.page.waitForFunction(() => {
const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";
});
const restored = await openNetworkPrompt( const restored = await openNetworkPrompt(
env, env,
@@ -5228,9 +4877,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before), isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints", "switching back did not restore the mainnet network and endpoints",
); );
await env.page.waitForFunction(
() => document.getElementById("debug-banner") === null,
);
}); });
// Switching the network in Settings tells the page too, as an approved site // Switching the network in Settings tells the page too, as an approved site
@@ -5380,20 +5026,8 @@ function addressRowReport(page) {
}); });
} }
// Measured at the popup's 396x600 (POPUP_VIEWPORT). Every page in this
// context, approval windows included, is otherwise 1280x720 whatever size
// the extension asks for, and a page that wide never scrolls sideways. The
// page is then put back to the size it had, so the size a later step runs at
// does not depend on whether this one ran.
async function assertAddressesFit(page, where) { async function assertAddressesFit(page, where) {
const sizeBefore = page.viewportSize(); const report = await addressRowReport(page);
await page.setViewportSize(POPUP_VIEWPORT);
let report;
try {
report = await addressRowReport(page);
} finally {
await page.setViewportSize(sizeBefore);
}
assert( assert(
report.rows.length > 0, report.rows.length > 0,
where + ": no address rows were rendered, so nothing was measured", where + ": no address rows were rendered, so nothing was measured",
@@ -5576,17 +5210,10 @@ async function main() {
seedReceipt: false, seedReceipt: false,
// Whether eth_getTransactionReceipt fails instead of answering (#315). // Whether eth_getTransactionReceipt fails instead of answering (#315).
failReceiptLookup: false, 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 // Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes // order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them. // rather than against anything the extension reported about them.
broadcastTransactions: [], 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; let session;
-114
View File
@@ -1,114 +0,0 @@
// 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 });
});
+1 -7
View File
@@ -35,11 +35,8 @@ global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); 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"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = { storage: makeStorageStub() };
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const { AbiCoder, Interface } = require("ethers"); const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants"); const { ERC20_ABI } = require("../src/shared/constants");
@@ -168,9 +165,6 @@ beforeEach(() => {
debugFetch.mockReset(); debugFetch.mockReset();
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; 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", () => { describe("what fetchTokenBalances stores for an absent scale", () => {
+1 -5
View File
@@ -21,11 +21,7 @@ jest.mock("../src/shared/log", () => ({
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
// Blockscout is asked only while the stored "Use Blockscout" is on. global.chrome = { storage: { local: {} } };
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const { const {
LOW_HOLDER_THRESHOLD, LOW_HOLDER_THRESHOLD,
+1 -8
View File
@@ -151,7 +151,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks"); const { NETWORKS } = require("../src/shared/networks");
@@ -436,13 +436,6 @@ describe.each([
}); });
test("the transaction detail screen's type line and fee", async () => { 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 () => ({ debugFetch.mockImplementationOnce(async () => ({
ok: true, ok: true,
status: 200, status: 200,
+2 -6
View File
@@ -286,12 +286,8 @@ describe("a malformed fraudContracts", () => {
function stubSendDocument() { function stubSendDocument() {
const select = { innerHTML: "", children: [] }; const select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child); select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = { globalThis.document = {
getElementById: (id) => elements[id] || null, getElementById: (id) => (id === "send-token" ? select : null),
createElement: () => ({ value: "", textContent: "" }), createElement: () => ({ value: "", textContent: "" }),
}; };
return select; return select;
@@ -368,7 +364,7 @@ describe("a save that fails", () => {
return storage; return storage;
} }
test("is reported when the caller does not await it", async () => { test("is reported, not swallowed by the save queue", async () => {
const storage = failingStorage(unversionedValidProfile()); const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } = const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage); loadStateModule(storage);
+2 -23
View File
@@ -465,19 +465,12 @@ const CONTRACT = [
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
contractAddress: 42, contractAddress: 42,
chainId: "0x1",
}, },
}, },
views: ["transaction"], views: ["transaction"],
restored: false, restored: false,
}, },
// A transaction whose chain id is missing or names no network has // A chain id naming no network has no explorer to link to.
// no explorer to link to and no Blockscout to be read from.
{
value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS } },
views: ["transaction"],
restored: false,
},
{ {
value: { value: {
tx: { tx: {
@@ -629,14 +622,6 @@ const CONTRACT = [
// the falsy answer is also the DEFAULT_STATE default. // the falsy answer is also the DEFAULT_STATE default.
falsy: [0], falsy: [0],
})), })),
// Each lets a request reach another service, so anything but true is off.
...["useBlockscout", "checkRecipientsOnEtherscan"].map((field) => ({
field,
kind: KIND.SCALAR,
hostile: [42, "notabool", { a: 1 }],
floorOnly: [0, null, "true"],
holds: (v) => v === false,
})),
]; ];
function siteMapHolds(v) { function siteMapHolds(v) {
@@ -851,13 +836,7 @@ const WELL_FORMED_DATA = {
message: "boom", message: "boom",
to: ADDRESS, to: ADDRESS,
decoded: { details: [{ address: TOKEN_ADDRESS }] }, decoded: { details: [{ address: TOKEN_ADDRESS }] },
tx: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: null,
chainId: "0x1",
},
pendingTx: { pendingTx: {
token: "ETH", token: "ETH",
from: ADDRESS, from: ADDRESS,
+1 -5
View File
@@ -44,9 +44,7 @@ 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 // A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet // request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559). TransactionDetail asks // (https://git.eeqj.de/sneak/AutistMask/issues/559).
// 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) { function restoringOnto(view, viewData) {
return unversionedValidProfile({ return unversionedValidProfile({
currentView: view, currentView: view,
@@ -54,8 +52,6 @@ function restoringOnto(view, viewData) {
selectedAddress: 0, selectedAddress: 0,
viewStack: ["main"], viewStack: ["main"],
viewData, viewData,
useBlockscout: true,
checkRecipientsOnEtherscan: true,
}); });
} }
-300
View File
@@ -1,300 +0,0 @@
// An open popup follows a network switch the background saves once the user
// approves a site's request: it takes the new network from the stored record
// and draws the screen it is on for it, as a switch in Settings does
// (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real
// popup over a stored profile on Ethereum Mainnet. The background's write is
// the storage stub's set, which the stub reports to the page as the browser
// does.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const { NETWORKS } = require("../src/shared/networks");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
afterEach(() => {
cleanupPopup();
});
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail,
// with DAI tracked there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() {
return bootPopup(profileOnAddress());
}
// The background saves a switch to `networkId`, as its updateState() does
// once the user approves a site's request.
async function backgroundSwitch(page, networkId) {
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, networkId);
await page.storage.local.set({ autistmask: record });
await page.settle();
await page.settle();
}
test("the popup open on AddressDetail shows the new network and its balances", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("DAI");
expect(page.node("debug-banner")).toBeNull();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).toContain("SepoliaETH");
expect(balances).not.toContain("DAI");
expect(page.text("debug-banner")).toBe("[TESTNET]");
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
});
test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["settings"]);
expect(page.value("settings-network")).toBe("sepolia");
expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST");
expect(page.text("debug-banner")).toBe("[TESTNET]");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).not.toContain("DAI");
});
// Neither can be drawn again for the new network: Send cannot be rendered from
// saved state, and DAI was picked on Ethereum Mainnet, where alone it is
// tracked. A reopened popup goes to Home in their place.
test.each([
["Send", (page) => page.click("btn-send")],
[
"AddressToken for DAI",
async (page) => {
const { state } = require("../src/shared/state");
state.selectedToken = MAINNET_TOKEN.address;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
},
],
])("the popup open on %s goes to Home", async (_view, open) => {
const page = await bootOnAddress();
await open(page);
expect(page.visibleViews()).not.toEqual(["address"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
});
// The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
const writes = set.mock.calls.length;
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.settle();
expect(get).toHaveBeenCalledTimes(reads + 1);
expect(set).toHaveBeenCalledTimes(writes + 1);
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
});
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("mainnet");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
test("a record that names no network is not followed", async () => {
const page = await bootOnAddress();
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
await set({
autistmask: {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [{ type: "hd", name: "Main", addresses: [] }],
},
});
await page.settle();
expect(get).toHaveBeenCalledTimes(reads);
expect(page.visibleViews()).toEqual(["address"]);
});
// A record that takes the switch from the switched record ends up as that
// record, balances the background fetched since included.
test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => {
const before = unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
networkEndpoints: {
sepolia: {
rpcUrl: "http://127.0.0.1:8545",
blockscoutUrl: "http://127.0.0.1:4000/api/v2",
},
},
lastBalanceRefresh: 1,
tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 },
fraudContracts: ["0x1111111111111111111111111111111111111111"],
});
const switched = structuredClone(before);
applyChainSwitchFields(switched, "sepolia");
switched.wallets[0].addresses[0].balance = "2.0";
const follower = structuredClone(before);
copyChainSwitchFields(follower, switched);
expect(follower).toEqual(switched);
});
+1 -1
View File
@@ -97,7 +97,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send"); const send = require("../src/popup/views/send");
+2 -6
View File
@@ -90,7 +90,7 @@ let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }), storage: makeStorageStub(),
runtime: { runtime: {
connect: () => ({ connect: () => ({
postMessage() {}, postMessage() {},
@@ -151,7 +151,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -220,10 +220,6 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 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( await refreshBalances(
state.wallets, state.wallets,
"https://rpc.example.invalid", "https://rpc.example.invalid",
+2 -9
View File
@@ -100,10 +100,7 @@ global.fetch = jest.fn(() => {
}); });
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element. // A stub DOM: every id resolves to a recording element.
const elements = new Map(); const elements = new Map();
@@ -149,7 +146,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { Transaction, Wallet, formatEther } = require("ethers"); const { Transaction, Wallet, formatEther } = require("ethers");
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
@@ -216,10 +213,6 @@ async function refreshWith(balanceWei, tokenItems = []) {
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 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( await refreshBalances(
state.wallets, state.wallets,
"https://rpc.example.invalid", "https://rpc.example.invalid",
+1 -5
View File
@@ -27,12 +27,8 @@ let select;
function installStubDocument() { function installStubDocument() {
select = { innerHTML: "", children: [] }; select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child); select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = { globalThis.document = {
getElementById: (id) => elements[id] || null, getElementById: (id) => (id === "send-token" ? select : null),
createElement: () => ({ value: "", textContent: "" }), createElement: () => ({ value: "", textContent: "" }),
}; };
} }
+1 -1
View File
@@ -160,7 +160,7 @@ describe("the approval-window reproduction", () => {
await approvalWindow.state.loadState(); await approvalWindow.state.loadState();
approvalWindow.helpers.showView("approve-tx"); approvalWindow.helpers.showView("approve-tx");
// showView() does not await its own saveState(); an extra save // showView() does not await its own saveState(); an extra save
// waits for the lock behind that one and so resolves only after it, // joins the same queue and only resolves once that one has too,
// which is the black-box way to know it landed. // which is the black-box way to know it landed.
await approvalWindow.state.saveState(); await approvalWindow.state.saveState();
+7 -10
View File
@@ -523,16 +523,13 @@ 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 () => { test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
// "Use Blockscout" is on. Otherwise the boot's balance refresh would const env = await bootPopup(
// drop the well-formed entry too, since the user has not tracked its profileWithTokenBalances([
// token. null,
const profile = profileWithTokenBalances([ 42,
null, { address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
42, ]),
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" }, );
]);
profile.useBlockscout = true;
const env = await bootPopup(profile);
const stored = env.storage.read("autistmask"); const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1"); expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
-96
View File
@@ -1,96 +0,0 @@
// A popup save reads the stored record, merges this page's changes into it
// and writes it back. The background's write of a network the user approved
// used to be able to land between that read and that write, and the popup's
// write then put the old network back
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every write of the record
// now holds a lock from its read to its write.
//
// The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and a page outside
// a Firefox private window share the browser's
// (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub");
function loadContexts(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return {
popup: require("../src/shared/state"),
background: require("../src/background/state"),
applyChainSwitchFields: require("../src/shared/chainSwitchFields")
.applyChainSwitchFields,
};
}
// Lets every pending promise callback run, and then some.
function nextMacrotask() {
return new Promise((resolve) => setImmediate(resolve));
}
afterEach(() => {
delete globalThis.chrome;
jest.restoreAllMocks();
});
test("a network the background switches to during a popup save stays stored", async () => {
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
await popup.loadState();
// The popup navigates, so its save has a change of its own to write.
popup.state.currentView = "settings";
// The popup's save has read the record and is about to write it back when
// the user's approval of a site's switch to mainnet reaches the
// background. Without the lock the background's whole update runs before
// the next macrotask; with it, the update waits for the popup's write.
let backgroundSwitch;
const realSet = storage.local.set.getMockImplementation();
storage.local.set.mockImplementationOnce(async (items) => {
backgroundSwitch = background.updateState((fresh) =>
applyChainSwitchFields(fresh, "mainnet"),
);
await nextMacrotask();
return realSet(items);
});
await popup.saveState();
await backgroundSwitch;
const stored = storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.currentView).toBe("settings");
});
test("after a save that failed, the next save still keeps a network the background switches to during it", async () => {
jest.spyOn(console, "error").mockImplementation(() => {});
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
await popup.loadState();
popup.state.currentView = "settings";
// The first save's write is refused, so it gives the lock back having
// failed.
storage.local.set.mockRejectedValueOnce(new Error("quota exceeded"));
await expect(popup.saveState()).rejects.toThrow("quota exceeded");
// The next save is between its read and its write when the background
// switches to mainnet, as in the test above.
let backgroundSwitch;
const realSet = storage.local.set.getMockImplementation();
storage.local.set.mockImplementationOnce(async (items) => {
backgroundSwitch = background.updateState((fresh) =>
applyChainSwitchFields(fresh, "mainnet"),
);
await nextMacrotask();
return realSet(items);
});
await popup.saveState();
await backgroundSwitch;
const stored = storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.currentView).toBe("settings");
});
-27
View File
@@ -1,27 +0,0 @@
// `navigator.locks` for the unit tests, which run on node, where there is none.
//
// One lock per name: granted in the order it was asked for, and let go when
// the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's,
// except a page in a Firefox private window (README, "Stored state and its
// version").
//
// Run before each test file by `setupFiles` in package.json.
// For each name, a promise that settles once the last request for it has
// let the lock go.
const released = new Map();
function request(name, callback) {
const previous = released.get(name) || Promise.resolve();
const result = previous.then(() => callback({ name, mode: "exclusive" }));
released.set(
name,
result.catch(() => {}),
);
return result;
}
globalThis.navigator = { locks: { request } };
+1 -3
View File
@@ -283,8 +283,6 @@ async function bootPopup(stored, options) {
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null), lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}), getProvider: () => ({}),
scanForAddresses: jest.fn(async () => ({ scanForAddresses: jest.fn(async () => ({
@@ -313,7 +311,7 @@ async function bootPopup(stored, options) {
const windowListeners = {}; const windowListeners = {};
globalThis.chrome = { globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged }, storage: { local: storage.local },
runtime: { runtime: {
sendMessage: jest.fn(async () => ({})), sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p, getURL: (p) => "chrome-extension://autistmask/" + p,
+5 -30
View File
@@ -18,9 +18,7 @@
// a storage stub by hand. // a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways. // One storage area: get, set and remove over its own store, cloning both ways.
// `changed` is handed what each set or remove changed, in the shape function makeArea(store, tick) {
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
const get = jest.fn(async (key) => { const get = jest.fn(async (key) => {
tick(); tick();
if (key === undefined || key === null) return structuredClone(store); if (key === undefined || key === null) return structuredClone(store);
@@ -36,25 +34,14 @@ function makeArea(store, tick, changed) {
const set = jest.fn(async (items) => { const set = jest.fn(async (items) => {
tick(); tick();
const changes = {};
for (const k of Object.keys(items)) { for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]); store[k] = structuredClone(items[k]);
} }
changed(changes);
}); });
const remove = jest.fn(async (key) => { const remove = jest.fn(async (key) => {
tick(); tick();
const changes = {}; for (const k of Array.isArray(key) ? key : [key]) delete store[k];
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
}); });
return { get, set, remove }; return { get, set, remove };
@@ -67,22 +54,12 @@ function makeArea(store, tick, changed) {
function makeStorageStub(initial, onOp) { function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {}; const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {}); const tick = onOp || (() => {});
// storage.onChanged: after a set or remove has finished, every listener is const local = makeArea(store, tick);
// called with what it changed and the area's name, as the browser calls
// them for a write by any page or the background.
const listeners = [];
const announce = (areaName) => (changes) => {
Promise.resolve().then(() => {
for (const fn of listeners) fn(structuredClone(changes), areaName);
});
};
const local = makeArea(store, tick, announce("local"));
return { return {
// Drop this straight in as chrome.storage. // Drop this straight in as chrome.storage.
local, local,
session: makeArea({}, tick, announce("session")), session: makeArea({}, tick),
onChanged: { addListener: (fn) => listeners.push(fn) },
get: local.get, get: local.get,
set: local.set, set: local.set,
remove: local.remove, remove: local.remove,
@@ -94,9 +71,7 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store) ? structuredClone(store)
: structuredClone(store[key]), : structuredClone(store[key]),
// Seed or replace a record without going through the module under // Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this". Nothing is // test — for standing in as "another page wrote this".
// announced to storage.onChanged, so it also stands in for the time
// before the browser tells this page of such a write.
write: (key, value) => { write: (key, value) => {
store[key] = structuredClone(value); store[key] = structuredClone(value);
}, },
+2 -12
View File
@@ -52,14 +52,8 @@ jest.mock("../src/shared/log", () => ({
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
// Blockscout is asked only while the stored "Use Blockscout" is on.
global.chrome = { global.chrome = {
storage: { storage: { local: { get: async () => ({}), set: async () => {} } },
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof"); const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
@@ -491,12 +485,8 @@ describe("surface 2: the Send token selector", () => {
function render(tokenBalances) { function render(tokenBalances) {
select = { innerHTML: "", children: [] }; select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child); select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = { globalThis.document = {
getElementById: (id) => elements[id] || null, getElementById: (id) => (id === "send-token" ? select : null),
createElement: () => ({ value: "", textContent: "" }), createElement: () => ({ value: "", textContent: "" }),
}; };
renderSendTokenSelect({ renderSendTokenSelect({
-2
View File
@@ -134,8 +134,6 @@ beforeEach(() => {
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
state.selectedToken = "ETH"; state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
}); });
describe.each([ describe.each([
+2 -55
View File
@@ -12,9 +12,7 @@
// whichever network is active // whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is // (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is
// read from that network's Blockscout, the one Settings has for it // read from that network's Blockscout, the one Settings has for it
// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name // (https://git.eeqj.de/sneak/AutistMask/issues/623).
// the token, and scale its amount, from what the wallet knows of that
// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
// //
// The row is the one the real history parser makes from the explorer's answer, // The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row // opened through transactionDetail.show(), which is what a click on the row
@@ -38,14 +36,8 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = { globalThis.chrome = {
storage: { storage: { local: { get: async () => ({}), set: async () => {} } },
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -164,7 +156,6 @@ beforeEach(() => {
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER; state.blockscoutUrl = EXPLORER;
state.useBlockscout = true;
state.networkEndpoints = {}; state.networkEndpoints = {};
state.wallets = [ state.wallets = [
{ {
@@ -174,7 +165,6 @@ beforeEach(() => {
}, },
]; ];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }]; state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
@@ -255,46 +245,3 @@ test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia tr
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH, NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
]); ]);
}); });
// Open the Sepolia transfer's detail screen with mainnet active, and wait for
// its decoded details.
async function showSepoliaTransferWithMainnetActive() {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
state.trackedTokens = [];
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
state.wallets[0].addresses[0].tokenBalances = [
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
];
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).not.toContain("MDOL");
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
-309
View File
@@ -1,309 +0,0 @@
// 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([]);
});
-2
View File
@@ -161,7 +161,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
); );
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet"); await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet"); expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]); expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
@@ -287,7 +286,6 @@ describe("Back after a switch from the Settings gear", () => {
currentView: "confirm-tx", currentView: "confirm-tx",
selectedWallet: 0, selectedWallet: 0,
selectedAddress: 0, selectedAddress: 0,
checkRecipientsOnEtherscan: true,
viewData: { viewData: {
pendingTx: { pendingTx: {
token: "ETH", token: "ETH",
-2
View File
@@ -125,8 +125,6 @@ beforeEach(() => {
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
state.selectedToken = "ETH"; state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
}); });
afterEach(() => { afterEach(() => {
-2
View File
@@ -1215,8 +1215,6 @@ function respondWith(txItems, tokenTransferItems) {
describe("fetchRecentTransactions merge and dedup", () => { describe("fetchRecentTransactions merge and dedup", () => {
beforeEach(() => { beforeEach(() => {
debugFetch.mockReset(); 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 () => { test("queries only the two Blockscout endpoints for the address", async () => {
-27
View File
@@ -150,8 +150,6 @@ beforeEach(() => {
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl; state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {}; state.networkEndpoints = {};
state.wallets = []; state.wallets = [];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
@@ -629,31 +627,6 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
expectSepoliaLinks(); expectSepoliaLinks();
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/629
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
function expectTokenNamed() {
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold">TDOL</div>',
);
}
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
const decoded = {
name: "Transfer",
details: [{ label: "Token", address: TOKEN, isToken: true }],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectTokenNamed();
elements.clear();
txStatus.renderSuccess();
expectTokenNamed();
});
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => { test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() { function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain( expect(getElement("error-tx-to").innerHTML).toContain(
+15 -2121
View File
File diff suppressed because it is too large Load Diff
+31 -643
View File
@@ -7,12 +7,9 @@
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks // balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap // (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), // leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and
// what each PERMIT2_TRANSFER_FROM moves // what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an // (https://git.eeqj.de/sneak/AutistMask/issues/611).
// UNWRAP_WETH makes the output ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/607 and
// https://git.eeqj.de/sneak/AutistMask/issues/642).
// //
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -42,14 +39,8 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = { globalThis.chrome = {
storage: { storage: { local: { get: async () => ({}), set: async () => {} } },
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
@@ -277,28 +268,18 @@ const SWAP_USDT_FOR_ETH = [
[[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]], [[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]],
), ),
]; ];
const SWAP_USDT_FOR_WETH = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, WETH, 500, 10, ZERO], true, USDT_2000, ONE_WETH, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"]; const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [ const SETTLE_USDT = [
0x0b, 0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
]; ];
// A TAKE of `amount`, by default zero: the open delta, all the step owes. function take(currency, recipient) {
function take(currency, recipient, amount = 0n) {
return [ return [
0x0e, 0x0e,
coder.encode( coder.encode(
["address", "address", "uint256"], ["address", "address", "uint256"],
[currency, recipient, amount], [currency, recipient, 0n],
), ),
]; ];
} }
@@ -406,18 +387,6 @@ describe("each swap command's recipient", () => {
data: (r) => execute(swapToWeth(r)), data: (r) => execute(swapToWeth(r)),
min: "1.0000 WETH", min: "1.0000 WETH",
}, },
{
// Its input has V3_SWAP_EXACT_IN's layout, with amountOut and
// amountInMax in place of amountIn and amountOutMin, and a path
// that lists the token bought first.
kind: "V3_SWAP_EXACT_OUT",
data: (r) =>
execute([
0x01,
v3SwapExactIn(r, ONE_WETH, USDT_2000, WETH, USDT)[1],
]),
min: "1.0000 WETH",
},
{ {
kind: "V4_SWAP with TAKE", kind: "V4_SWAP with TAKE",
data: (r) => data: (r) =>
@@ -550,7 +519,7 @@ describe("an output left in the router is not received", () => {
{ {
kind: "an UNWRAP_WETH to the user, then the swap", kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter), data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 WETH", min: "1.0000 ETH",
}, },
{ {
kind: "the swap, then a WRAP_ETH of zero to the user", kind: "the swap, then a WRAP_ETH of zero to the user",
@@ -587,530 +556,6 @@ describe("an output left in the router is not received", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/607: an UNWRAP_WETH pays out,
// as ETH, the WETH the router holds when it runs. Token Out read ETH whenever
// the output side was WETH, so a swap that paid its WETH to the user before
// the UNWRAP_WETH read as ETH, though the user receives WETH.
describe("an UNWRAP_WETH makes the output ETH only when the router holds it", () => {
test("a swap paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(swapToWeth(MSG_SENDER), unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
test("a swap paying its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH", () => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The SWEEP pays the user all the WETH the router holds, so the
// UNWRAP_WETH finds none of it.
test("a swap paying its WETH to the router, then a SWEEP of it to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
sweep(WETH, MSG_SENDER),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
// The user receives the first step's WETH as WETH and the second's as
// ETH. The second step sets the output side and pays its WETH to the
// router, so the output reads as ETH with the second step's minimum.
test("two swaps, the second paying its WETH to the router, then an UNWRAP_WETH, reads as ETH", () => {
const result = decode(
execute(
swapToWeth(MSG_SENDER),
v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
});
// The same two swaps the other way round. The user receives the first
// step's WETH as ETH and the second's as WETH. The second step sets the
// output side and pays its WETH to the user, so the output reads as WETH
// with the second step's minimum, though the router holds the first's.
test("two swaps, the second paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(
v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
swapToWeth(MSG_SENDER),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
// One V4 step buys at least 1 WETH and TAKEs 0.6 WETH to the user and the
// rest to the router. The user receives 0.6 WETH and the rest as ETH.
// Neither minimum is shown: the decoder does not count how much of the
// step's minimum reaches the router, nor read the UNWRAP_WETH's own.
test("one V4 step paying part of its WETH to the user and the rest to the router, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
withDirtyWord(take(WETH, MSG_SENDER), 1),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// A step after the UNWRAP_WETH that sets the output side sets it anew.
test("a swap paying WETH to the user after an UNWRAP_WETH of an earlier swap's WETH reads as WETH", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
v2Swap(0x08, MSG_SENDER, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
});
// The EXECUTE_SUB_PLAN's own steps, which the decoder does not read, buy
// the user USDC as well, so Token Out is not named.
test("a swap paying its WETH to the router, then an UNWRAP_WETH, then an EXECUTE_SUB_PLAN, names no Token Out", () => {
const subPlan = [
0x21,
coder.encode(
["bytes", "bytes[]"],
[
"0x00",
[
v3SwapExactIn(
MSG_SENDER,
USDT_2000,
USDC_1990,
USDT,
USDC,
)[1],
],
],
),
];
const result = decode(
execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER), subPlan),
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token Out").value).toBe(UNDECODED);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/642: a step between the swap and
// the UNWRAP_WETH may take WETH out of the router or pay it WETH, and a step
// after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each
// route read as ETH with the swap's minimum. A step the decoder read that is a
// PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION of a token other than WETH, and not of ETH to a
// WETH contract, cannot take WETH out of the router or pay anyone WETH; the
// decoder treats every other step as one that may.
describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
(l) => l !== "Min. received",
);
const NO_MINIMUM_ELSEWHERE = PAID_ELSEWHERE.filter(
(l) => l !== "Min. paid out",
);
test.each([
{
kind: "a TRANSFER of 0.0025 WETH",
step: transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips",
step: transfer(0x06, WETH, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15",
step: transfer(0x07, WETH, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with no minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test.each([
{
kind: "a SWEEP whose recipient cannot be read",
step: withDirtyWord(sweep(WETH, OTHER), 1),
},
{
kind: "a V4 SETTLE of 0.5 WETH from the router",
step: v4Swap([
0x0b,
coder.encode(
["address", "uint256", "bool"],
[WETH, ONE_WETH / 2n, false],
),
]),
},
{
kind: "a bridge deposit of 0.5 WETH",
step: acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
},
{
kind: "a PERMIT2_TRANSFER_FROM of 0.5 WETH into the router",
step: permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH / 2n),
},
])("$kind between them reads as ETH with no minimum", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
test.each([
{
kind: "a PERMIT2_PERMIT between them",
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 of another address between them",
steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 of the sender with a zero minimum between them",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "a SWEEP of USDC between them",
steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)],
},
{
kind: "an UNWRAP_WETH into the router, then a SWEEP of its ETH,",
steps: [unwrapWeth(ADDRESS_THIS), sweep(ZERO, MSG_SENDER)],
},
])("$kind reads as ETH with the swap's minimum", ({ steps }) => {
const result = decode(execute(swapToWeth(ADDRESS_THIS), ...steps));
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test("a V4 step that TAKEs its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH with the swap's minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test.each([
{
kind: "a TRANSFER of 100 USDC",
step: transfer(0x05, USDC, OTHER, 100000000n),
},
{
kind: "a PAY_PORTION of 25 bips of USDC",
step: transfer(0x06, USDC, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of USDC",
step: transfer(0x07, USDC, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
test.each([
{
kind: "a TRANSFER of 0.0025 ETH",
step: transfer(0x05, ZERO, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips of its ETH",
step: transfer(0x06, ZERO, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of its ETH",
step: transfer(0x07, ZERO, OTHER, 10n ** 15n),
},
])(
"an UNWRAP_WETH into the router, then $kind to another address, then a SWEEP of its ETH to the user, reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
sweep(ZERO, MSG_SENDER),
),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
sweep(WETH, MSG_SENDER),
wrapEth(ADDRESS_THIS, ONE_WETH),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The router reads the recipient from the last 20 bytes of its word,
// here address(2), the router itself.
test("an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// The first UNWRAP_WETH may pay the router the swap's output as ETH, and
// the WRAP_ETH to the user pays all of it out as WETH, so the last
// UNWRAP_WETH finds none of it.
test.each([
{
kind: "an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user",
steps: [
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
],
},
{
kind: "an UNWRAP_WETH into the router, a WRAP_ETH of all its ETH to the user, then a WRAP_ETH of all its ETH into the router",
steps: [
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
],
},
])(
"$kind, then an UNWRAP_WETH to the user, reads as WETH with no minimum",
({ steps }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
...steps,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A SWEEP of WETH pays out WETH, and ETH paid to a WETH contract pays the
// router WETH for it. The decoder treats each as a step that may pay out
// as WETH the swap's output, which the router holds as ETH.
test.each([
{
kind: "a SWEEP of WETH to the user",
step: sweep(WETH, MSG_SENDER),
},
{
kind: "a SWEEP of its ETH to the WETH contract",
step: sweep(ZERO, WETH),
},
{
kind: "a TRANSFER of 0.5 ETH to the WETH contract",
step: transfer(0x05, ZERO, WETH, ONE_WETH / 2n),
},
{
kind: "a PAY_PORTION of all its ETH to the WETH contract",
step: transfer(0x06, ZERO, WETH, 10000n),
},
])(
"an UNWRAP_WETH into the router, then $kind, reads as WETH with no minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, ONE_WETH / 2n),
sweep(ZERO, MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
// the ETH an earlier V4 step paid the router.
test("an UNWRAP_WETH to the user, then a WRAP_ETH of all the router's ETH to the user, reads as ETH", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The first swap is for at least 0.5 WETH and the second for at least
// 1 WETH. The second sets the output side again, so the first
// UNWRAP_WETH into the router no longer bears on it.
test.each([
{ kind: "a V3 swap step", step: swapToWeth(ADDRESS_THIS) },
{
kind: "a V4 step that TAKEs its WETH to the router",
step: v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
},
])(
"a swap paying WETH to the router, an UNWRAP_WETH into the router, a SWEEP of its ETH to the user, then $kind and an UNWRAP_WETH to the user, reads as ETH with the second swap's minimum",
({ step }) => {
const result = decode(
execute(
v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
ONE_WETH / 2n,
USDT,
WETH,
),
unwrapWeth(ADDRESS_THIS),
sweep(ZERO, MSG_SENDER),
step,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
},
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the // https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
// router stays there unless a later step pays out or spends all of it, not // router stays there unless a later step pays out or spends all of it, not
// only the swap's output. A V4 SETTLE can take more of the user's tokens than // only the swap's output. A V4 SETTLE can take more of the user's tokens than
@@ -1226,7 +671,6 @@ describe("any token left in the router is not received", () => {
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
spent: "2000.0000 USDT",
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router", kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router",
@@ -1253,7 +697,6 @@ describe("any token left in the router is not received", () => {
v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for", kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for",
@@ -1262,7 +705,6 @@ describe("any token left in the router is not received", () => {
v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC), v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user", kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user",
@@ -1275,7 +717,6 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
}, },
{ {
kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router", kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router",
@@ -1338,10 +779,6 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), 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", 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",
@@ -1355,13 +792,9 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
// The step has two SETTLEs of WETH, so what the user pays is not
// known.
spent: UNDECODED,
}, },
// The user pays for the swap by a SETTLE_ALL or SETTLE whose currency // The user pays for the swap by a SETTLE_ALL or SETTLE whose currency
// the decoder cannot read, so the SETTLE from the router pays nothing, // the decoder cannot read, so the SETTLE from the router pays nothing.
// and what the user pays is not known.
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router", kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router",
data: execute( data: execute(
@@ -1374,7 +807,6 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
unread: true,
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router", kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router",
@@ -1388,7 +820,6 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
unread: true,
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router", kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router",
@@ -1442,51 +873,22 @@ describe("any token left in the router is not received", () => {
), ),
left: { label: "Token moved into the router", value: "ETH" }, left: { label: "Token moved into the router", value: "ETH" },
}, },
])("$kind", ({ data, left, spent, unread, noMin }) => { ])("$kind", ({ data, left }) => {
const result = decode(data); const result = decode(data);
expect(labels(result)).toEqual([ expect(labels(result)).toEqual([
"Protocol", "Protocol",
"Token In", "Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read "Amount",
// may take anything from the sender: one line says so, in place
// 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
? ["Moved from the sender", "Token moved", "Moved to"]
: []),
"Token Out", "Token Out",
"Paid to", "Paid to",
"Token moved into the router", "Token moved into the router",
...(noMin ? [] : ["Min. paid out"]), "Min. paid out",
"Steps", "Steps",
"Deadline", "Deadline",
]); ]);
if (spent) {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
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(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left); expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out")?.value).toBe( expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
noMin ? undefined : "1990.0000 USDC",
);
}); });
// Uniswap's own routes: a swap paid in ETH wraps into the router what its // Uniswap's own routes: a swap paid in ETH wraps into the router what its
@@ -2094,6 +1496,15 @@ describe("a payout of part of the router's output", () => {
), ),
min: "1.0000 WETH", min: "1.0000 WETH",
}, },
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{ {
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL", kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute( data: execute(
@@ -2582,6 +1993,10 @@ describe("a payout that cannot be read is never said to be received", () => {
), ),
], ],
}, },
{
kind: "V3_SWAP_EXACT_OUT",
step: [0x01, swapToWeth(OTHER)[1]],
},
{ {
kind: "EXECUTE_SUB_PLAN", kind: "EXECUTE_SUB_PLAN",
step: [ step: [
@@ -2814,37 +2229,26 @@ describe("the allow-revert flag", () => {
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// The permit's spender is the router at ROUTER, which on every other
// router is an allowance to another spender, so the minimum reads
// Min. paid out there.
test.each([ test.each([
{ { router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{ {
router: "UniversalRouterV1_2_V2Support", router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2, address: ROUTER_V1_2,
mark: "", mark: "",
minimum: "Min. paid out",
}, },
{ {
router: "UniversalRouterV2", router: "UniversalRouterV2",
address: ROUTER, address: ROUTER,
mark: " (allowed to fail)", mark: " (allowed to fail)",
minimum: "Min. received",
}, },
{ {
router: "UniversalRouterV2_1_2", router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2, address: ROUTER_V2_1_2,
mark: " (allowed to fail)", mark: " (allowed to fail)",
minimum: "Min. paid out",
}, },
])( ])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag", "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark, minimum }) => { ({ address, mark }) => {
const steps = [ const steps = [
permit2Permit(USDT, USDT_2000), permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER), swapToWeth(MSG_SENDER),
@@ -2858,25 +2262,10 @@ describe("the allow-revert flag", () => {
expect(detail(withFlag, "Steps").value).toBe( expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)", "Permit2 Permit" + mark + " → V3 Swap (Exact In)",
); );
// The permit names the router at ROUTER as its spender, so its const withoutSteps = (r) =>
// amount stands as the Amount figure only there, and only without r.details.filter((d) => d.label !== "Steps");
// the flag. With the flag it stands on no router and gets lines expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
// of its own, and the swap step names Token In: the same token, expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
// 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");
}, },
); );
@@ -3462,7 +2851,6 @@ async function showDetail(data) {
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = "mainnet"; state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2"; state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
+18 -100
View File
@@ -7,12 +7,6 @@
// only when the transaction is sent to a Universal Router deployment on the // only when the transaction is sent to a Universal Router deployment on the
// network it is for; anywhere else it is left to the raw data. // network it is for; anywhere else it is left to the raw data.
// //
// The transaction history labelled any call named `execute` or `multicall` a
// Swap, whatever address it went to
// (https://git.eeqj.de/sneak/AutistMask/issues/580). It now does so only for
// such a call sent to a Universal Router deployment on the network the
// transaction is on; any other is labelled by its method name.
//
// The transaction detail screen and the approval screen are driven against a // The transaction detail screen and the approval screen are driven against a
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the // minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
// approval screen opened the way tests/swapStatusAmount.test.js opens it. // approval screen opened the way tests/swapStatusAmount.test.js opens it.
@@ -34,18 +28,11 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = { globalThis.chrome = {
storage: { storage: { local: { get: async () => ({}), set: async () => {} } },
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval"); const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval; const { decodeCalldata } = approval;
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
@@ -60,8 +47,6 @@ const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The address with no code from the issue. // The address with no code from the issue.
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A"; const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
// A contract that is not a router, such as a multisig.
const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
@@ -144,13 +129,8 @@ describe("execute() calldata on the approval screen's decoder", () => {
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`. // The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
const TX_HASH = "0x" + "5".repeat(64); const TX_HASH = "0x" + "5".repeat(64);
let mockSentTo = null; let mockSentTo = null;
// The transactions the explorer reports for RECIPIENT's history.
let mockHistory = [];
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
if (url.endsWith("/addresses/" + RECIPIENT + "/transactions")) {
return { items: mockHistory };
}
if (url.endsWith("/transactions/" + TX_HASH)) { if (url.endsWith("/transactions/" + TX_HASH)) {
return { return {
to: { hash: mockSentTo }, to: { hash: mockSentTo },
@@ -207,43 +187,33 @@ function node(id) {
} }
// Open the transaction detail screen with `activeNetworkId` active, for the // Open the transaction detail screen with `activeNetworkId` active, for the
// history row `row`, and wait for its decoded details. // Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
async function showDetail(row, activeNetworkId) { // decoded details.
mockSentTo = row.to; async function showDetailSentTo(sentTo, activeNetworkId) {
mockSentTo = sentTo;
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId; state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2"; state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
transactionDetail.show(row); transactionDetail.show({
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
} }
// Open the transaction detail screen with `activeNetworkId` active, for the
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo, activeNetworkId) {
await showDetail(
{
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
},
activeNetworkId,
);
}
describe("execute() calldata on the transaction detail screen", () => { describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => { test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "sepolia"); await showDetailSentTo(SEPOLIA_ROUTER, "sepolia");
@@ -275,58 +245,6 @@ describe("execute() calldata on the transaction detail screen", () => {
}); });
}); });
// The history row made from the explorer's answer for RECIPIENT's `method`
// call sent to the contract `sentTo` on the network with `chainId`. The
// explorer gives the address in checksummed form.
async function historyRow(method, sentTo, chainId) {
mockHistory = [
{
hash: TX_HASH,
block_number: 7000000,
timestamp: "2026-09-01T00:00:00Z",
from: { hash: RECIPIENT },
to: { hash: getAddress(sentTo), is_contract: true },
value: "0",
method,
status: "ok",
},
];
const rows = await fetchRecentTransactions(
RECIPIENT,
"https://explorer.example/api/v2",
chainId,
);
return rows[0];
}
// The history row's label and TransactionDetail's Type line, for a Sepolia
// transaction. The active network does not decide either.
describe.each([
["execute", "Execute"],
["multicall", "Multicall"],
])("a %s() call in the history", (method, methodLabel) => {
test("sent to a router on its network, it is a Swap", async () => {
const row = await historyRow(method, SEPOLIA_ROUTER, SEPOLIA);
expect(row.directionLabel).toBe("Swap");
await showDetail(row, "mainnet");
expect(node("tx-detail-type").textContent).toBe("Swap");
});
test("sent to another contract, it is labelled by its method name", async () => {
const row = await historyRow(method, OTHER_CONTRACT, SEPOLIA);
expect(row.directionLabel).toBe(methodLabel);
await showDetail(row, "sepolia");
expect(node("tx-detail-type").textContent).toBe("Contract Call");
});
test("sent to a router of the other network only, it is labelled by its method name", async () => {
const row = await historyRow(method, MAINNET_ROUTER, SEPOLIA);
expect(row.directionLabel).toBe(methodLabel);
await showDetail(row, "mainnet");
expect(node("tx-detail-type").textContent).toBe("Contract Call");
});
});
// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia // Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
// transaction that sends SWAP_DATA to `sentTo`. // transaction that sends SWAP_DATA to `sentTo`.
async function showApprovalSentTo(sentTo) { async function showApprovalSentTo(sentTo) {
-3
View File
@@ -90,9 +90,6 @@ function swapDetail(data, label) {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; 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", () => { describe("a swap of a token outside the bundled list", () => {
+2 -9
View File
@@ -72,12 +72,8 @@ global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); 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"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM. Every id in index.html that these two views touch resolves to a // 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 // fresh recording element; nothing here depends on layout, only on what the
@@ -124,7 +120,7 @@ global.document = {
body: { prepend() {}, appendChild() {} }, body: { prepend() {}, appendChild() {} },
addEventListener() {}, addEventListener() {},
}; };
global.navigator.clipboard = { writeText() {} }; global.navigator = { clipboard: { writeText() {} } };
const { AbiCoder, parseUnits } = require("ethers"); const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances"); const { fetchTokenBalances } = require("../src/shared/balances");
@@ -272,9 +268,6 @@ beforeEach(() => {
state.fraudContracts = []; state.fraudContracts = [];
state.hideLowHolderTokens = false; state.hideLowHolderTokens = false;
state.currentView = null; 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", () => { describe("the Send screen resolves the scale rather than reading the stored one", () => {
+1 -1
View File
@@ -475,7 +475,7 @@ describe("every way to a signature or the private key refuses a defective wallet
to: RECIPIENT, to: RECIPIENT,
value: "0x0", value: "0x0",
data: "0x", data: "0x",
chainId: "0x1", chainId: 1,
nonce: 0, nonce: 0,
gasLimit: "21000", gasLimit: "21000",
maxFeePerGas: "1000000000", maxFeePerGas: "1000000000",