18 Commits
Author SHA1 Message Date
clawbot 26a2a51387 fix: two pages switching networks no longer lose tracked tokens (closes #701)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A page that had not yet taken another page's network switch could lose
tracked tokens.

- Its save wrote its network's list as the new network's list. saveState()
  now keeps that list and merges the change into its own network's list in
  networkTrackedTokens.
- Its own switch wrote its old per-network lists over the stored ones.
  onChainSwitch() now first takes the stored switch with takeStoredNetwork().
- takeStoredNetwork() dropped the page's unsaved change. It now keeps it in
  the list of the page's network. This also fixes
  #706.
- A page already on the stored network now takes the stored
  networkTrackedTokens before it switches, because another page's save may
  have changed a list this page holds.

Model: opus-5-5
2026-11-03 00:04:20 +01:00
clawbot b5737f915c fix: a transaction history that lands after a network switch is dropped (closes #657)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Home, AddressDetail and AddressToken remember which network is active
before they ask Blockscout for the transaction history. Each screen drops
the answer if another network is active on the page or in storage when the
answer lands. AddressDetail and AddressToken also drop an answer that lands
once the screen shows another address. AddressToken also drops an answer
that lands once the screen shows another token.

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

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

Model: opus-5-5
2026-11-02 23:41:24 +01:00
clawbot 016acbcda5 fix: Settings ignores an endpoint check once the same "Save" is tapped again (closes #698)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Each RPC and Blockscout "Save" in Settings now counts its taps. A check
that answers after a newer tap of the same "Save" stores nothing and
leaves the flash message alone. Before this change, the older check's
error or refusal replaced the newer tap's "Saved.".

The save also checks for a newer tap at two points. A tap made while the
save waits for the lock means nothing is stored. A tap made during the
write means no flash message is shown.

Model: opus-5-5
2026-11-02 22:59:23 +01:00
clawbot 7ababcf69e fix: the balance list and the add-token screens apply the spoof rule to tracked tokens (closes #705)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A tracked token that the spoof rule refuses still got a zero row in the balance list while "Show tracked tokens with zero balance" was on. balanceLinesForAddress() now asks isSpoofedSymbol() before it adds that row. AddToken and SettingsAddToken refuse such a token after the contract lookup, with the flash "Not added: this token fakes a known symbol." The bundled-list buttons on SettingsAddToken need no check: the rule never refuses a bundled token. README.md and docs/README.md now say the balance list drops such a token whatever the settings.

Model: opus-5-5
2026-11-02 06:04:07 +01:00
clawbot 3191577f73 test: make test fails when jest force-stops a worker (closes #711)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When a test left a timer running, jest force-stopped the worker that ran the test and printed "A worker process has failed to exit gracefully", but the run still passed. Both test scripts in package.json now run jest through script/lib/jest.js. The helper runs jest inside its own process, so the terminal output is unchanged, and it exits 1 when jest writes that warning to stderr. Jest 30 has no option that fails the run on it. The eslint.config.js comment on script/lib/ now says those files run on plain node and are never bundled.

Model: opus-5-5
2026-11-02 01:44:22 +01:00
clawbot acb2577fec test: the test suite exits on its own, without --forceExit (closes #585)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Some tests left timers running when they ended, and --forceExit hid them; both test scripts drop it. Six background test files left the background's 20-second timer running while an approval was pending; each now stubs it for the whole file. Five popup test files left a flash message's two-second timer running; they now use fake timers, and a wait on real zero-delay timeouts became jest.advanceTimersByTimeAsync(0). Two ethers timers can still run for up to 250 ms after a test ends; they stop before jest checks for open handles.

Model: opus-5-5
2026-10-28 01:09:12 +01:00
clawbot a33a40a6fa build: say beside the install why yarn prints the workspaces warning (closes #595)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Yarn 1 prints "Workspaces can only be enabled in private projects." for each dependency whose package.json has a workspaces field without "private": true. Two do: eslint 10.8.1, and babel-plugin-istanbul 7.0.1, which jest pulls in. No update removes the field, and removing the warning would mean downgrading both below what this repo and jest require. A one-line comment beside the install in script/bootstrap and in the Dockerfile now names both packages and says why the warning stays; every Dockerfile install runs script/bootstrap, so one comment there is enough.

Model: opus-5-5
2026-10-28 00:28:27 +01:00
clawbot 5192419cac docs: README matches ConfirmTx's wrong-password message and fee-bound refusal (closes #610)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
README's ConfirmTx section quoted "Wrong password." for a wrong password; the screen says "That password is incorrect. Please try again.", and the README now quotes that. The other password screens describe their error without quoting it, and DeleteWallet already quoted it right. The fee-bound refusal no longer says the RPC endpoint always fills in the gas limit and fees at signing: for an ETH amount that Max filled in, they come from the screen's own fee estimate. The transition names `assertWithinCeilings()` and lists what it checks.

Model: opus-5-5
2026-10-28 00:05:20 +01:00
clawbot 41842b197e fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When the contract lookup on AddToken answered, the popup went to AddressDetail even after the user had opened another screen. It now goes there only while AddToken shows and has not been opened again since Add was pressed; AddToken counts its openings as Send does. The token is still added as before unless the network changed. An answer, found or failed, from an earlier opening leaves the screen and the status line alone, so a later lookup's "Looking up token..." stays.

Judgement call: a user who went Back to AddressDetail during the lookup sees the new token once that screen is drawn again.

Model: opus-5-5
2026-10-27 23:16:10 +01:00
clawbot 8998d6c661 fix: a refresh asked for while another runs is no longer dropped (closes #581)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
doRefreshAndRender() returned at once when a refresh was already running. An import asks for a refresh after its address scan, and that request was dropped when it came during a refresh, so the addresses the scan found showed 0.0000 until the next ten-second refresh. A request made through ctx while a refresh runs now leads to one more refresh once the running one ends, however many such requests came in. A tick of the ten-second timer during a refresh is still dropped, so the wallet asks the node no more often than before.

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-27 07:27:15 +01:00
42 changed files with 3220 additions and 201 deletions
+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
RUN script/bootstrap
COPY . .
+67 -47
View File
@@ -905,8 +905,8 @@ src/
prices.js — ETH/USD and token/USD from Chainlink price feeds
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
read from storage for the requests those settings gate
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
the active network, read from storage
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
price feeds (hardcoded)
@@ -1277,22 +1277,18 @@ see Transaction Decoding), do not always read as a number. They can read:
step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known. The router passes a balance
check whenever the balance is at least `minBalance`, so a zero `minBalance`
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does. Only a check of the
transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
a V2, V3 or V4 swap's minimum of zero. The router passes a
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
lines, as a swap step does. Only a check of the transaction's sender's own
balance sets either line; a check of any other address sets neither (see
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail
without reverting the transaction (see Transaction Decoding) guarantees
nothing either, whatever its `minBalance`, and is read as one whose
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail
is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1477,21 +1473,20 @@ tracked.
A tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. A token claiming a symbol from the bundled list from any other
contract address is always dropped, tracked or not, and so is any token claiming
a symbol that belongs to the native asset and therefore has no legitimate
contract at all (`"ETH"`, and every network's `nativeCurrency`, such as
`"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. A token's holder count is
unknown when the explorer reports none, or reports anything other than a whole
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
or more, so such a token is shown only when it is on the bundled list or
tracked. Every nonzero holding of a token shown is stored exactly, however
small, but a holding below 0.000001 is left out of the balance lists, the
send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
more holders. The balance list always drops a token that claims a bundled-list
symbol from another contract address, whatever the settings. The list also drops
every token that claims the native asset's symbol: `"ETH"`, or any network's
`nativeCurrency`, such as `"SepoliaETH"`. That filter is unconditional — the
"Hide tokens with fewer than 1,000 holders" setting governs the transaction
history and the send-screen token selector, not this list. A token's holder
count is unknown when the explorer reports none, or reports anything other than
a whole number written in digits alone, such as `1,000` or `1e3`
(`parseHoldersCount()` in `src/shared/holders.js`). This list does not take an
unknown count as 1,000 or more, so such a token is shown only when it is on the
bundled list or tracked. Every nonzero holding of a token shown is stored
exactly, however small, but a holding below 0.000001 is left out of the balance
lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
@@ -2057,15 +2052,21 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's
explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
is more than this wallet will sign for, so it was not sent. Please go back
and try again." in its own box, the same sentence for all three, no screen
change
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" (correct password) when the gas limit or a fee is over its
bound → nothing is signed; "The gas limit or fee the RPC endpoint quoted
for this transaction is more than this wallet will sign for, so it was not
sent. Please go back and try again." in its own box, no screen change. For
an ETH amount that Send's "Max" filled in, the gas limit and fees are
those of this screen's own fee estimate (see Amount above). For any other
send, the RPC endpoint fills in the gas limit and fees at signing. Just
before signing, `assertWithinCeilings()` (`src/shared/approvalVerify.js`)
refuses the send when any of these is over its bound:
- the gas limit
- each fee per gas the transaction sets
- the total fee: the gas limit times `maxFeePerGas`, or times `gasPrice`
when the transaction has no `maxFeePerGas`
- "Sign & Send" (wrong password) → "That password is incorrect. Please try
again." on the password error line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
@@ -2237,6 +2238,10 @@ view would leave a wallet one click from deletion.
**AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Add" with a token that the spoof rule refuses → nothing is tracked; "Not
added: this token fakes a known symbol." flash message, no screen change
- "Add", then the contract lookup answers while another screen shows, or
after AddToken was opened again → tracks the token, no screen change
- "Add", then a network switch in Settings before the contract lookup
answers → nothing is tracked; "The active network changed before the
lookup finished, so the token was not added." flash message, no screen
@@ -2281,6 +2286,13 @@ view would leave a wallet one click from deletion.
- Endpoint URL input + "Save" button. "Save" asks the URL for `/stats`
and saves the URL only if that request succeeds. The request carries
no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so
- Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not
saved
- If the user taps the same "Save" again before the endpoint answers, the
earlier tap stores nothing and shows no flash message
- Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user
ticks it
@@ -2538,6 +2550,9 @@ view would leave a wallet one click from deletion.
"Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Add" by contract address with a token that the spoof rule refuses →
nothing is tracked; "Not added: this token fakes a known symbol." flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the
lookup answers → nothing is tracked; "The active network changed before
the lookup finished, so the token was not added." flash message, no screen
@@ -3395,7 +3410,8 @@ Currently supported:
`Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out
@@ -3564,10 +3580,14 @@ Currently supported:
on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose
amount does not stand as the `Amount` figure, before the step that sets the
input side or after it, gives three lines, in step order, after those for each
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
where it goes, and so its amount is shown once. For a move of the zero
address, `Moved from the sender` gives the amount in base units and
`Token moved` gives the zero address. For such a move into the router, the
`Paid to` line gives the amount in base units and
`Token moved into the router` gives the zero address. Every `PERMIT2_PERMIT`
whose amount does not stand as the `Amount` figure, before the step that sets
the input side or after it, gives three lines, in step order, after those for
each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose
+2 -4
View File
@@ -380,10 +380,8 @@ but comes from an unrecognized contract, it is identified as a spoof and hidden.
In your transaction history this is the "Hide fake tokens impersonating a known
symbol" setting, which you can switch off; doing so also stops new entries being
added to the fraud contract blocklist below, since detecting a spoof is what
fills it. The send token list always applies the check. Your balances apply it
too, with one exception: a token claiming the symbol "ETH" is not filtered
there, so a fake "ETH" token can still show up in your balance list even though
it is hidden from your transaction history and from the send token list.
fills it. The send token list always applies the check. Your balance list always
applies the check too.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list, and are left out of your
+1 -2
View File
@@ -153,8 +153,7 @@ module.exports = [
},
},
// The helpers the script/ entrypoints call: plain node programs too, run
// from a shell script rather than from yarn, and never bundled.
// The files in script/lib/ run on plain node too, and are never bundled.
{
files: ["script/lib/**/*.js"],
languageOptions: {
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"test": "node script/lib/jest.js --maxWorkers=3",
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
+1
View File
@@ -118,6 +118,7 @@ ensure_yarn() {
fi
}
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
+40
View File
@@ -0,0 +1,40 @@
// Runs jest with the arguments it is given, and fails the run when jest had to
// force-stop the worker process that ran a test file. Jest itself only prints a
// warning when that happens, and its run still passes. The warning means a test
// left a timer or another open handle running. The test scripts in package.json
// run jest through this file.
//
// Jest runs inside this process and writes to the terminal directly, so its
// output looks the same as a plain jest run. This file only checks each write
// to stderr for the warning below.
"use strict";
// The start of the warning that jest prints on stderr after it force-stops a
// worker process.
const WORKER_WARNING = "A worker process has failed to exit gracefully";
let forceStopped = false;
const writeStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => {
if (String(chunk).includes(WORKER_WARNING)) {
forceStopped = true;
}
return writeStderr(chunk, ...rest);
};
// Runs after jest has finished, as the process exits.
process.on("exit", () => {
if (forceStopped) {
console.error(
"script/lib/jest.js: failing the run, because jest force-stopped " +
"a worker process. A test left a timer or another open " +
"handle running.",
);
process.exitCode = 1;
}
});
// Jest's own command-line program. It reads its arguments from process.argv.
require("jest/bin/jest");
+27 -10
View File
@@ -65,7 +65,14 @@ function renderWalletList() {
const pageClosed = new AbortController();
window.addEventListener("pagehide", () => pageClosed.abort());
// While a refresh runs, a request through ctx is not dropped. The request sets
// refreshAgain, and one more refresh runs once the running one ends. Several
// requests in that time lead to one more refresh, not several. An import asks
// for a refresh after its address scan. A refresh already running does not
// have the addresses the scan found
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
let refreshInFlight = false;
let refreshAgain = false;
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
// the recovery screen: there is no profile left to refresh.
@@ -89,7 +96,10 @@ let refreshTimer = null;
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
async function doRefreshAndRender() {
if (refreshInFlight) return;
if (refreshInFlight) {
refreshAgain = true;
return;
}
refreshInFlight = true;
try {
await followStoredSettings();
@@ -132,28 +142,35 @@ async function doRefreshAndRender() {
});
renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
// reject: an unhandled rejection is not a report of anything. The save
// inside it reports its own failure through onSaveFailure() (see
// src/shared/state.js); what is left here is a failed network round
// trip, which the next tick retries.
// No caller waits for this function, so it must never reject. An
// unhandled rejection reports nothing. The save inside it reports its
// own failure through onSaveFailure() (see src/shared/state.js); what
// is left here is a failed network round trip, which the next tick
// retries.
log.errorf("popup: background refresh failed:", e);
} finally {
refreshInFlight = false;
}
if (refreshAgain) {
refreshAgain = false;
doRefreshAndRender();
}
}
// Refreshes now and every ten seconds from then on. Called once a wallet
// exists: by init() when the popup opens with one, and by AddWallet when it
// adds one, which in a popup that opened on Welcome is the first
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
// running it starts no second one, and on the recovery screen it does nothing.
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
// refresh already started, it starts no second one. On the recovery screen it
// does nothing.
function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender();
if (refreshTimer === null) {
refreshTimer = setInterval(doRefreshAndRender, 10000);
// A tick that finds a refresh running is dropped.
refreshTimer = setInterval(() => {
if (!refreshInFlight) return doRefreshAndRender();
}, 10000);
}
}
+28
View File
@@ -3,9 +3,15 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before
// AddToken was last opened changes neither the screen nor the status line.
let addTokenScreenOpenings = 0;
function show() {
addTokenScreenOpenings++;
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
@@ -55,6 +61,7 @@ function init(ctx) {
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
const opening = addTokenScreenOpenings;
try {
const info = await lookupTokenInfo(
contractAddr,
@@ -71,6 +78,15 @@ function init(ctx) {
);
return;
}
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked.
if (isSpoofedSymbol(info.symbol, contractAddr)) {
showFlash("Not added: this token fakes a known symbol.");
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,
@@ -80,6 +96,17 @@ function init(ctx) {
});
await saveState();
ctx.doRefreshAndRender();
// If the lookup answers after AddToken was opened again, the
// token is still added, but the screen and the status line stay
// as they are.
if (addTokenScreenOpenings !== opening) return;
// The lookup is over, so a later Back onto AddToken shows no
// status.
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
// If the lookup answers while another screen shows, the token is
// still added but the screen does not change.
if (state.currentView !== "add-token") return;
// Pop the stack (back to address detail) and re-render it
// so the newly added token is visible immediately.
if (state.viewStack.length > 0) {
@@ -100,6 +127,7 @@ function init(ctx) {
? detail
: "Could not add the token.",
);
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+47 -14
View File
@@ -22,6 +22,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -98,6 +99,7 @@ let loadedTxs = [];
let ensNameMap = new Map();
async function loadTransactions(address) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -114,43 +116,74 @@ async function loadTransactions(address) {
});
const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
names = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
// The rows then show no ENS names.
}
}
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
+49 -14
View File
@@ -29,6 +29,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -170,6 +171,7 @@ function show() {
}
async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -186,16 +188,6 @@ async function loadTransactions(address, tokenId) {
});
let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only
if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null);
@@ -207,32 +199,75 @@ async function loadTransactions(address, tokenId) {
);
}
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
names = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
// The rows then show no ENS names.
}
}
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
+4
View File
@@ -33,6 +33,7 @@ const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter");
@@ -447,6 +448,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
// A token that the spoof rule refuses has no stored balance, and
// gets no zero row either.
if (isSpoofedSymbol(t.symbol, t.address)) continue;
html += balanceLine(
t.symbol,
0,
+30 -2
View File
@@ -44,6 +44,7 @@ const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log");
function findActiveAddr() {
@@ -183,6 +184,7 @@ async function loadHomeTxs(ctx) {
}
if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
@@ -202,6 +204,15 @@ async function loadHomeTxs(ctx) {
);
const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set();
let merged = [];
@@ -215,14 +226,24 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (filtered.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
merged = filtered.transactions;
@@ -234,6 +255,13 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list");
if (list) {
list.innerHTML =
+82 -7
View File
@@ -44,6 +44,17 @@ let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
// The network that Settings was last drawn for. A follow of a switch saved
// elsewhere sets `state` to the new network first. Settings is drawn again only
// once the follow's write returns. So both "Save" buttons take this network,
// not the one in `state`.
let shownNetwork = null;
// Each endpoint "Save" counts its taps here, under the field that it stores.
// Once the user taps the same "Save" again, the earlier tap shows no flash
// message. The earlier tap stores its URL only if its write had already begun.
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
@@ -199,6 +210,7 @@ function renderWalletListSettings() {
}
function show() {
shownNetwork = currentNetwork();
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId;
@@ -306,6 +318,7 @@ function showNewSettings() {
state.checkRecipientsOnEtherscan;
if (state.currentView === "settings") {
markViewRendered("settings");
shownNetwork = currentNetwork();
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
@@ -319,17 +332,70 @@ function showNewSettings() {
updateDebugBanner(state.currentView);
}
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
// `networkId`, the one that Settings showed when the user tapped "Save". The
// URL is set in `state` inside the save, holding the lock. So the page cannot
// follow a switch saved elsewhere (followStoredSettings() in
// src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
//
// `input` is the id of the input that the user typed `url` in. When another
// page changes the network, "Use Blockscout" or "Check recipients on
// Etherscan", this page draws Settings again. A redraw during the endpoint
// check puts the URL from `state` back in the input. So if the input no longer
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
//
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
// the same "Save" again before this save holds the lock, nothing is stored. If
// the user taps the same "Save" again before the write ends, no flash message
// is shown.
async function saveEndpoint(field, input, url, networkId, tap) {
let refused = false;
let inputChanged = false;
await saveState((stored) => {
if (saveTaps[field] !== tap) return false;
refused =
state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url;
if (refused || inputChanged) return false;
state[field] = url;
stored[field] = url;
});
if (saveTaps[field] !== tap) return;
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
showFlash(
"The URL was not saved because it was changed during the check.",
);
} else {
showFlash("Saved.");
}
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
saveTaps.rpcUrl++;
const tap = saveTaps.rpcUrl;
const url = $("settings-rpc").value.trim();
if (!url) {
showFlash("Please enter an RPC URL.");
return;
}
// The URL is for the network that Settings shows when "Save" is tapped.
const net = shownNetwork;
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url, {
@@ -343,12 +409,13 @@ function init(_ctx) {
}),
});
const json = await resp.json();
// The user has tapped this "Save" again since the check began.
if (tap !== saveTaps.rpcUrl) return;
if (json.error) {
log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error.");
return;
}
const net = currentNetwork();
if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + ".");
return;
@@ -357,6 +424,7 @@ function init(_ctx) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.rpcUrl) return;
// Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
@@ -364,34 +432,41 @@ function init(_ctx) {
showFlash("Could not reach endpoint.");
return;
}
state.rpcUrl = url;
await saveState();
showFlash("Saved.");
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
});
$("btn-save-blockscout").addEventListener("click", async () => {
saveTaps.blockscoutUrl++;
const tap = saveTaps.blockscoutUrl;
const url = $("settings-blockscout").value.trim();
if (!url) {
showFlash("Please enter a Blockscout API URL.");
return;
}
const net = shownNetwork;
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url + "/stats");
if (tap !== saveTaps.blockscoutUrl) return;
if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch {
if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.blockscoutUrl) return;
// Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint.");
return;
}
state.blockscoutUrl = url;
await saveState();
showFlash("Saved.");
await saveEndpoint(
"blockscoutUrl",
"settings-blockscout",
url,
net.id,
tap,
);
});
// Each change is made inside the save, holding the lock. So
+11
View File
@@ -3,6 +3,7 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log");
let ctx;
@@ -155,6 +156,16 @@ function init(_ctx) {
);
return;
}
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked. The rule never refuses
// a bundled token, so the "Top tokens:" buttons and the top 100
// dropdown need no such check.
if (isSpoofedSymbol(info.symbol, addr)) {
showFlash("Not added: this token fakes a known symbol.");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
+12
View File
@@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) {
}
}
// Whether TransactionDetail still holds the transaction with `txHash` on
// `chainId`. Opening another transaction replaces the one TransactionDetail
// holds. A visit to Settings does not.
function holdsTransaction(txHash, chainId) {
const tx = state.viewData.tx;
return tx?.hash === txHash && tx?.chainId === chainId;
}
// Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown.
//
// Blockscout can answer after the user has opened another transaction. That
// answer is dropped.
async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section");
@@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return;
const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!holdsTransaction(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId);
+53 -2
View File
@@ -47,12 +47,63 @@ function clearPopupCaches() {
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
//
// The save writes only the fields that differ from what this page last saved.
// When this page last saved a balance of "0", clearing that balance is no
// change. So the switch also resets these fields in the stored record:
//
// - every address's balance and token balances;
// - the time of the last balance refresh;
// - the fraud contracts.
//
// A balance or a fraud contract that the background or another page stored
// since this page's last save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// When the stored record is on another network, a switch saved elsewhere has
// not reached this page yet. This page first takes that switch with
// takeStoredNetwork() (state.js), as followStoredSettings() does. The switch to
// `newNetworkId` is then made from the stored record's network. So every
// network's list of tracked tokens in the stored record is kept
// (https://git.eeqj.de/sneak/AutistMask/issues/701). When the stored record is
// already on `newNetworkId`, the switch made from there only resets the
// balances and caches.
//
// When the stored record is on this page's network, this page first takes the
// stored `networkTrackedTokens` (takeStoredNetworkTrackedTokens() in state.js),
// because another page's save may have changed a list this page holds.
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on.
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const {
state,
saveState,
takeStoredNetwork,
takeStoredTrackedTokens,
takeStoredNetworkTrackedTokens,
} = require("./state");
let net = null;
await saveState(() => {
await saveState((stored) => {
if (state.networkId === newNetworkId) return false;
if (stored.networkId !== state.networkId) {
takeStoredNetwork(stored);
} else {
takeStoredNetworkTrackedTokens(stored);
}
takeStoredTrackedTokens(stored);
net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
clearPopupCaches();
});
return net;
+103 -13
View File
@@ -125,11 +125,12 @@ function currentNetwork() {
return networkById(state.networkId);
}
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
// `baseline` holds the persisted fields as this page held them when its last
// loadState() finished, or when its last successful saveState() began.
// takeStoredNetwork(), takeStoredTrackedTokens() and
// takeStoredNetworkTrackedTokens() also write into it what this page has taken
// from the stored record since then. saveState() diffs the live state against
// `baseline` to find only the fields THIS page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -401,6 +402,23 @@ function mergeNetworkEndpoints(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
}
// Returns a copy of `stored.networkTrackedTokens` for when this page has not yet
// taken the switch in `stored`. `page` is this page's state. A token that this
// page tracked or removed since its last save is tracked or removed in the list
// of `page.networkId`, by contract address.
function networkTrackedTokensWithChange(stored, page) {
return {
...stored.networkTrackedTokens,
[page.networkId]: mergeListByIdentity(
baseline.trackedTokens,
page.trackedTokens,
stored.networkTrackedTokens[page.networkId],
(token) => token.address.toLowerCase(),
(base, ours) => ours,
),
};
}
// Read-modify-write, merged per field, rather than one full-blob write.
//
// Every extension page (the toolbar popup, a dApp approval window) holds its
@@ -442,8 +460,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// milder than the other two.
//
// Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset
@@ -452,6 +472,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// own UI scratch space, not data another page has any reason to share
// membership of.
//
// One exception: when the stored record is on another network than this
// page's, a save keeps the stored `trackedTokens`. It merges this page's change
// into the list in `networkTrackedTokens` for this page's network, by contract
// address.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
@@ -524,6 +549,20 @@ async function saveStateOnce(beforeWrite) {
merged[key] = current[key];
}
}
// When the merged record names a network other than this page's, this page
// has not yet taken a switch saved elsewhere. This page's `trackedTokens`
// is then the list of this page's network. The stored record keeps the
// list of this page's network in `networkTrackedTokens`. So a token that
// this page added or removed since its last save is added to or removed
// from that stored list. The merged record keeps the stored `trackedTokens`
// (https://git.eeqj.de/sneak/AutistMask/issues/701).
if (merged.networkId !== current.networkId) {
merged.trackedTokens = fresh.trackedTokens;
merged.networkTrackedTokens = networkTrackedTokensWithChange(
fresh,
current,
);
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
// Stamped on every write, never merged or diffed: the record that goes to
// storage is in THIS build's shape whatever shape it was read in, which is
@@ -591,8 +630,10 @@ function reportSaveFailure(err) {
//
// `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is
// written; any other return lets the save go ahead.
// `state`, and this save writes that change. It may also change the stored
// record. A field that this page did not change is then written as
// `beforeWrite` left it. When it returns false, nothing is written; any other
// return lets the save go ahead.
function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this
@@ -602,15 +643,62 @@ function saveState(beforeWrite) {
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
// Give this page the network switch held by `stored`, in `state` and in
// `baseline` alike, as loadState() does with a whole record. `stored` is the
// record that a save of this page has just read. followStoredSettings() in
// src/popup/index.js and onChainSwitch() in src/shared/chainSwitch.js call this
// from `beforeWrite`. 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.
//
// A token that this page tracked or removed since its last save was tracked or
// removed on this page's network before the switch. `state` keeps that change
// in the list in `networkTrackedTokens` for that network, merged with the
// stored list by contract address. `baseline` takes the stored list, so the
// save stores the change (https://git.eeqj.de/sneak/AutistMask/issues/706).
function takeStoredNetwork(stored) {
const networkTrackedTokens = networkTrackedTokensWithChange(
stored,
rawState,
);
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
rawState.networkTrackedTokens = networkTrackedTokens;
}
// Give this page the tracked tokens of `stored`, a record on this page's
// network that a save of this page has just read (onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`):
//
// - A token that another page tracked or removed since this page's last save
// is tracked or removed in `state` too.
// - A token that this page tracked or removed since then stays as this page
// has it.
//
// `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ.
function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity(
baseline.trackedTokens,
rawState.trackedTokens,
stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
);
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Give `state` and `baseline` each their own copy of the stored
// `networkTrackedTokens`. `stored` is a record on this page's network that a
// save of this page has just read. onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`.
function takeStoredNetworkTrackedTokens(stored) {
rawState.networkTrackedTokens = structuredClone(
stored.networkTrackedTokens,
);
baseline.networkTrackedTokens = structuredClone(
stored.networkTrackedTokens,
);
}
// Rejects with StateUnusableError for a stored record this build cannot make
@@ -659,6 +747,8 @@ module.exports = {
saveState,
onSaveFailure,
takeStoredNetwork,
takeStoredTrackedTokens,
takeStoredNetworkTrackedTokens,
loadState,
currentAddress,
currentNetwork,
+19 -3
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// profile.
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
// from the stored profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,6 +7,11 @@
// made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just
// changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState");
@@ -24,4 +29,15 @@ async function storedSettingIsOn(name) {
}
}
module.exports = { storedSettingIsOn };
// Whether `networkId` is the active network in the stored profile. A failed
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+35 -44
View File
@@ -479,6 +479,17 @@ function tokenInfo(address, sources) {
};
}
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
// The zero address there reads as a token nothing knows: its amount in base
// units, and the zero address as its token.
function permit2TokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: null, decimals: null, address };
}
return tokenInfo(address, sources);
}
// A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
@@ -497,16 +508,18 @@ function amountText(raw, info) {
// The line giving a moved token, or the token of a permit, under `label`, in
// the shape of the Token In line, so the screens show it as they show that
// one. ETH has no address.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
// ETH has no address.
function tokenMovedLine(label, info) {
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
value: info.symbol
? info.symbol + " (" + info.address + ")"
: info.address,
address: info.address,
isToken: true,
};
}
@@ -1616,13 +1629,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
// The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees
// nothing and does not replace a minimum an earlier
// step stated. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. Any
// nothing. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. A zero
// check sets neither Token Out nor the minimum. Any
// other minBalance sets the output side as a swap
// does. It pays nobody.
// does. The check pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) {
if (minBalance !== 0n) {
setOutput(i, b.token, minBalance, []);
}
keepsRouterWeth = true;
@@ -2329,7 +2342,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources));
details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push({
label: "Moved to",
value: "The swap step that spends it",
@@ -2340,12 +2355,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
value: amountText(m.amount, info).display,
});
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(tokenMovedLine("Token moved", info));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
@@ -2375,36 +2390,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
continue;
}
// Permit2 has no allowance of ETH. An allowance of the zero
// address reads as one of a token nothing knows: its amount in
// base units, and the zero address as its token.
if (p.token === "0x0000000000000000000000000000000000000000") {
const info = permit2TokenInfo(p.token, sources);
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: unknownDecimalsAmount(p.amount),
: amountText(p.amount, info).display,
});
details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push(tokenMovedLine("Allowance token", info));
details.push({
label: "Allowance spender",
value: p.spender,
@@ -2462,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
const info = permit2TokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
@@ -2470,9 +2464,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
paidTo.push(tokenMovedLine("Token moved into the router", info));
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2489,8 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
getAddress(token),
sources,
tokenInfo(getAddress(token), sources),
),
);
}
+137
View File
@@ -0,0 +1,137 @@
// A contract lookup on AddToken can answer after the user has left AddToken.
// The token is still added, and the screen the user is on does not change
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
// still shows goes to AddressDetail. Driven through the real popup over a
// stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, then AddToken opened from it with
// TOKEN's contract address typed in.
async function openAddToken() {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = TOKEN.address;
return boot;
}
// Presses Add with a contract lookup that answers only when the function
// returned here is called. That function waits for Add to finish.
function addWithPendingLookup(boot) {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click("btn-add-token-confirm");
return async () => {
answer({
symbol: TOKEN.symbol,
decimals: TOKEN.decimals,
name: TOKEN.name,
});
await adding;
};
}
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await answerLookup();
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token...");
await boot.click("btn-settings");
await answerLookup();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("");
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
});
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
await answerFirstLookup();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("Looking up token...");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const { lookupTokenInfo } = require("../src/shared/balances");
let failFirstLookup;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((_, reject) => {
failFirstLookup = reject;
}),
);
const firstAdd = boot.click("btn-add-token-confirm");
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
failFirstLookup(new Error("lookup failed"));
await firstAdd;
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await answerLookup();
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).toContain("TST");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
+6 -1
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
// ------------------------------------------------------------ harness
@@ -188,7 +188,11 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
// showFlash() starts a timer that clears the message one or two seconds later.
// Each test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
@@ -239,6 +243,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
+11 -12
View File
@@ -537,7 +537,18 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve));
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
@@ -1883,18 +1894,6 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1];
}
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
+11
View File
@@ -103,7 +103,18 @@ function deferred() {
return { promise, resolve };
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+51
View File
@@ -6,6 +6,10 @@
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
//
// The last test covers a balance refresh by the background that is saved while
// a switch in Settings waits for the lock
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
const {
bootPopup,
@@ -159,3 +163,50 @@ test("a refresh that returns after the background switched the network writes no
expectNothingStored(page);
});
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// token balances, the values that the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
await page.click("btn-settings");
// The background takes the lock first and holds it until the test calls
// `letGo()`. It then saves balances as backgroundRefresh() in
// src/background/index.js does.
let letGo;
const refreshSave = background.updateState(async (fresh) => {
await new Promise((resolve) => {
letGo = resolve;
});
fresh.wallets[0].addresses[0].balance = "7.0";
fresh.wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
fresh.lastBalanceRefresh = Date.now();
});
await page.settle();
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
letGo();
await refreshSave;
await switching;
await page.settle();
expectNothingStored(page);
});
+11
View File
@@ -56,7 +56,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -64,7 +64,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -76,7 +76,18 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -57,7 +57,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
+11
View File
@@ -102,6 +102,17 @@ function makeDocument() {
// ------------------------------------------------------------ harness
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function wallet(name, secret, addresses) {
return {
type: "hd",
+498
View File
@@ -0,0 +1,498 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check after the name lookup can drop the
// answer.
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () =>
history().slice(0, 1),
);
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
+265
View File
@@ -253,6 +253,271 @@ test("a switch back in Settings after a follow whose write failed is stored", as
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the
// URL is stored, the background saves a switch to Sepolia. Another write holds
// the lock, so the popup's follow of that switch and the URL's save both wait
// for the lock. Either may take it first. The URL is stored for neither
// network. The flash message says that the network changed.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc", first: "follow" },
{ button: "btn-save-rpc", field: "settings-rpc", first: "save" },
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "follow",
},
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "save",
},
])(
"$button saves nothing when the network changes before the URL is stored ($first first)",
async ({ button, field, first }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks. The RPC check reads
// Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The click handler runs until its save waits for the lock.
const tapSave = async () => {
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
return saving;
};
let saving;
if (first === "save") saving = await tapSave();
await backgroundSwitch(page, "sepolia");
if (first === "follow") saving = await tapSave();
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The popup's follow of the background's switch to Sepolia has set `state` to
// Sepolia. The follow's write has not returned yet, so Settings still shows
// Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum
// Mainnet, so the save stores the URL for neither network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing when tapped while the follow's write waits",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
const { set } = page.storage.local;
const write = set.getMockImplementation();
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
// The background's write lands; the next, the follow's, waits.
set.mockImplementationOnce(write).mockImplementationOnce(
async (items) => {
await writeHeld;
await write(items);
},
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(page.value("settings-network")).toBe("mainnet");
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseWrite();
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for
// the node's answer, the popup follows the background's switch to Sepolia. The
// node answers with Sepolia's chain id. So the check refuses the node for
// Ethereum Mainnet, and nothing is stored.
test("the RPC check compares the answer with the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://sepolia-node.example.invalid";
page.node("settings-rpc").value = url;
const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"Wrong network: expected Ethereum Mainnet.",
);
});
// The user taps the Blockscout "Save" on Ethereum Mainnet. While the check
// waits for Blockscout's answer, the popup follows the background's switch to
// Sepolia. The URL is for Ethereum Mainnet, so nothing is stored.
test("the Blockscout save is for the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://own-explorer.example.invalid/api/v2";
page.node("settings-blockscout").value = url;
const saving = page
.node("btn-save-blockscout")
.listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
expect(page.value("settings-network")).toBe("sepolia");
answer({ ok: true });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
});
// Settings is open when the network changes to Sepolia. The popup follows the
// background's switch, or the user picks Sepolia in the network selector. Each
// "Save" after the switch stores its URL for Sepolia.
test.each([
["a follow", (page) => backgroundSwitch(page, "sepolia")],
[
"the network selector",
async (page) => {
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
},
],
])(
"after a switch to Sepolia by %s, Settings saves each URL for Sepolia",
async (_how, switchToSepolia) => {
const page = await bootOnAddress();
await page.click("btn-settings");
await switchToSepolia(page);
// This answer passes both endpoint checks on Sepolia. The RPC check
// reads Sepolia's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.sepolia.chainId }),
});
const rpcUrl = "https://own-node.example.invalid";
const blockscoutUrl = "https://own-explorer.example.invalid/api/v2";
page.node("settings-rpc").value = rpcUrl;
await page.click("btn-save-rpc");
expect(page.text("flash-msg")).toBe("Saved.");
page.node("settings-blockscout").value = blockscoutUrl;
await page.click("btn-save-blockscout");
expect(page.text("flash-msg")).toBe("Saved.");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.rpcUrl).toBe(rpcUrl);
expect(stored.blockscoutUrl).toBe(blockscoutUrl);
},
);
// The user picks Sepolia in Settings, but the switch's write fails. The page is
// then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save"
// after the failed switch stores nothing for either network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing after a switch in Settings whose write failed",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
page.storage.local.set.mockRejectedValueOnce(
new Error("quota exceeded"),
);
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) {
await expect(fn()).rejects.toThrow("quota exceeded");
}
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
page.node(field).value = url;
await page.click(button);
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
+157
View File
@@ -0,0 +1,157 @@
// While a refresh runs, a request through ctx is not dropped. One more refresh
// runs once the running one ends, however many were asked for in that time
// (https://git.eeqj.de/sneak/AutistMask/issues/581). A tick of the ten-second
// timer that finds a refresh running is dropped. Each test boots the real popup
// over a stored profile and holds the ten-second refresh open. `readChain()`
// sets each address's balance from `CHAIN`, as the real `refreshBalances()`
// sets it from the node.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const {
hdWalletFromMnemonic,
deriveAddressFromXpub,
} = require("../src/shared/wallet");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
// The first two addresses of PHRASE, both used on the chain.
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
const SECOND_ADDRESS = deriveAddressFromXpub(xpub, 1);
// The ETH balance the chain holds for each address, in ether.
const CHAIN = {
[ADDRESS]: "1.5",
[firstAddress]: "2.0",
[SECOND_ADDRESS]: "3.0",
};
afterEach(() => {
cleanupPopup();
});
// The balance stand-in for a refresh that returns.
async function readChain(wallets) {
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
addr.balance = CHAIN[addr.address];
}
}
}
// Boots the popup over a profile with one wallet. From then on,
// `refreshBalances()` never returns unless the test answers it. So an unwanted
// refresh is counted once and then waits, instead of starting more.
async function boot() {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementation(() => new Promise(() => {}));
return { page, refreshBalances };
}
// Starts the ten-second refresh and holds it open until finish() is called.
// The held refresh then writes the chain's balances. finish() resolves once
// the held refresh has saved them and drawn Home. The one refresh after the
// held one also returns.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances
.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
await readChain(wallets);
})
.mockImplementationOnce(readChain);
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Imports PHRASE on AddWallet. Its address scan finds the phrase's first two
// addresses.
async function importPhrase(page) {
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockImplementationOnce(async () => ({
addresses: [
{ address: firstAddress, index: 0 },
{ address: SECOND_ADDRESS, index: 1 },
],
nextIndex: 2,
}));
await page.click("btn-add-wallet-bottom");
await page.click("tab-mnemonic");
page.node("wallet-mnemonic").value = PHRASE;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// Imports the private key `key` on AddWallet.
async function importKey(page, key) {
await page.click("btn-add-wallet-bottom");
await page.click("tab-privkey");
page.node("import-private-key").value = key;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
test("addresses that an import finds during a refresh show their balances once one more refresh returns", async () => {
const { page } = await boot();
const refresh = await holdRefresh(page);
await importPhrase(page);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("flash-msg")).toBe("Found 2 addresses.");
await refresh.finish();
await page.settle();
const list = page.node("wallet-list").innerHTML;
expect(list).toContain("<span>2.0000</span>");
expect(list).toContain("<span>3.0000</span>");
});
test("several requests during a refresh lead to exactly one more refresh", async () => {
const { page, refreshBalances } = await boot();
const refresh = await holdRefresh(page);
const calls = refreshBalances.mock.calls.length;
await importKey(page, "0x" + "11".repeat(32));
await importKey(page, "0x" + "22".repeat(32));
await importPhrase(page);
expect(refreshBalances).toHaveBeenCalledTimes(calls);
await refresh.finish();
await page.settle();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
test("a tick of the ten-second timer during a refresh starts no other refresh", async () => {
const { page, refreshBalances } = await boot();
const refresh = await holdRefresh(page);
const calls = refreshBalances.mock.calls.length;
await page.tick();
await refresh.finish();
await page.settle();
expect(refreshBalances).toHaveBeenCalledTimes(calls);
});
+9 -1
View File
@@ -131,10 +131,14 @@ async function review(amount, token) {
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
state.wallets = [
{
@@ -167,6 +171,10 @@ beforeEach(() => {
state.currentView = null;
});
afterEach(() => {
jest.useRealTimers();
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
+9 -1
View File
@@ -277,14 +277,18 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
function canSend() {
return !el("btn-confirm-send").disabled;
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
log.errorf.mockClear();
pageClosed = new AbortController();
@@ -297,6 +301,10 @@ beforeEach(() => {
mockNode.broadcast = [];
});
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI);
+438
View File
@@ -0,0 +1,438 @@
// Another page stores a different RPC or Blockscout URL for the same network.
// Then the user taps "Save" in Settings on this page, on the URL that this page
// loaded. The URL on screen is stored, and the flash message reads "Saved."
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
// popup over a stored profile on Ethereum Mainnet.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
afterEach(() => {
cleanupPopup();
});
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores the URL on screen after another page stored a different one",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const url = page.value(input);
// This write sends this page no storage change event.
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
[field]: "https://other-page.example.invalid",
});
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
// reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.click(button);
expect(page.storage.read("autistmask")[field]).toBe(url);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". While the check waits for
// the endpoint's answer, another page turns "Use Blockscout" on. This page
// follows that change and draws Settings again, with the stored URL in the
// input. The check then passes. The typed URL is not stored. The flash message
// says that the URL was changed during the check
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again during the check",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await Promise.all(saving);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// As above, but another write holds the lock. So this page's follow of the
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
// input still shows the typed URL. The save then asks for the lock, after the
// follow did. So the follow draws Settings again before the save compares the
// input.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again while the save waits for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.settle();
expect(page.value(input)).toBe(typedUrl);
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// This answer passes both endpoint checks on Ethereum Mainnet.
const PASSING_ANSWER = {
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
};
// Both endpoint checks read this answer as an error. The RPC check reads the
// answer's `error`. The Blockscout check reads the answer's `ok`.
const ERROR_ANSWER = {
ok: false,
status: 500,
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
};
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user types a second URL and taps "Save" again. The second check passes
// first. The second URL is stored, and the flash message reads "Saved.". The
// first check then ends in one of three ways:
//
// - It passes.
// - It fails, because fetch cannot reach the endpoint.
// - It gets an answer that holds an error.
//
// None of the three changes the stored URL or the flash message
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "passes",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "fails",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "answers with an error",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "passes",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "fails",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "answers with an error",
},
])(
"$button keeps the second tap's result when the first check $first last",
async ({ button, input, field, first }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const secondUrl = "https://second.example.invalid";
page.node(input).value = "https://first.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = secondUrl;
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
checks[1].resolve(PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Saved.");
if (first === "passes") {
checks[0].resolve(PASSING_ANSWER);
} else if (first === "answers with an error") {
checks[0].resolve(ERROR_ANSWER);
} else {
// fetch rejects when it cannot reach the endpoint.
checks[0].reject(new TypeError("Failed to fetch"));
}
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user empties the input and taps "Save" again. The flash message asks for
// a URL. The first check then passes. The flash message still asks for a URL,
// and the stored URL is unchanged.
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
message: "Please enter an RPC URL.",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
message: "Please enter a Blockscout API URL.",
},
])(
"$button keeps the empty-input message when the first check passes last",
async ({ button, input, field, message }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = "";
await page.click(button);
expect(page.text("flash-msg")).toBe(message);
answer(PASSING_ANSWER);
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(message);
},
);
// The user types a URL in Settings and taps "Save". Another write holds the
// lock. So the save waits for the lock once the check passes. The user then
// taps "Save" again on the same URL. When the lock is free, the first save
// stores nothing and shows no flash message. The second check then passes, and
// the second save stores the URL.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing for an earlier tap still waiting for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve) => {
checks.push(resolve);
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
checks[0](PASSING_ANSWER);
await page.settle();
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseLock();
await lockHeld;
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe("Testing endpoint...");
checks[1](PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user taps "Save", and the check passes. The save then begins its storage
// write, and this test keeps the write waiting. The user taps "Save" again, and
// the second check cannot reach the endpoint. When the write ends, the flash
// message still reads "Could not reach endpoint.". The write stores the URL
// anyway, because the write began before the second tap.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button keeps the second tap's message when tapped again during the write",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
const { set } = page.storage.local;
const write = set.getMockImplementation();
let writeBegan = false;
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
set.mockImplementationOnce(async (items) => {
writeBegan = true;
await writeHeld;
await write(items);
});
checks[0].resolve(PASSING_ANSWER);
await page.settle();
expect(writeBegan).toBe(true);
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// fetch rejects when it cannot reach the endpoint.
checks[1].reject(new TypeError("Failed to fetch"));
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
releaseWrite();
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
},
);
+91
View File
@@ -0,0 +1,91 @@
// A token that the spoof rule refuses never shows in the balance list, even
// tracked at zero balance with "Show tracked tokens with zero balance" on, and
// the two add-token screens refuse to track one
// (https://git.eeqj.de/sneak/AutistMask/issues/705). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// Claims the symbol of the bundled USDT from another contract.
const FAKE_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Tether USD",
};
// A symbol the bundled list does not have, so the spoof rule passes it.
const TEST_TOKEN = {
address: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const REFUSED_FLASH = "Not added: this token fakes a known symbol.";
afterEach(() => {
cleanupPopup();
});
test("a fake token tracked at zero balance is not listed while zero balances are shown", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [FAKE_USDT, TEST_TOKEN],
showZeroBalanceTokens: true,
}),
);
const rows = boot.node("address-balances").innerHTML;
expect(rows).toContain(`data-token="${TEST_TOKEN.address.toLowerCase()}"`);
expect(rows).not.toContain(
`data-token="${FAKE_USDT.address.toLowerCase()}"`,
);
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token",
"add-token-address",
"btn-add-token-confirm",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("%s", (_, opening, view, field, button) => {
test("refuses a fake token by contract address", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: FAKE_USDT.symbol,
decimals: FAKE_USDT.decimals,
name: FAKE_USDT.name,
});
boot.node(field).value = FAKE_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).toBe(REFUSED_FLASH);
expect(boot.visibleViews()).toEqual([view]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([]);
});
});
+501
View File
@@ -5,6 +5,9 @@
// a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js.
//
// The tests of what another page stored before a switch in Settings cover a
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
const {
bootPopup,
@@ -177,6 +180,504 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
expect(onSepolia).not.toContain("DAI");
});
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
// last save. This page still holds DAI. The switch to Sepolia does not store
// DAI under Ethereum Mainnet again.
test("a token another page removed before a switch in Settings stays removed", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page removes DAI after this page's last save. That page then
// adds DAI again, typing the contract address in lower case. The two pages'
// entries for DAI match, because contract addresses are compared ignoring
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
// Mainnet.
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page saves a switch to Sepolia after this page's last save.
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page adds a token on Ethereum Mainnet. The add's
// save adds the token to Ethereum Mainnet's list and leaves Sepolia's list as
// it was.
test("a token added before this page takes another page's switch is tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. This page does not track DAI. Before taking that switch, this page
// adds a token on Ethereum Mainnet. The add's save puts both DAI and the new
// token on Ethereum Mainnet's list.
test("a token added before this page takes another page's switch joins that page's tokens on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page removes DAI on Ethereum Mainnet, as the `[x]`
// on a tracked token in Settings does. The removal's save drops DAI from
// Ethereum Mainnet's list and leaves Sepolia's list as it was.
test("a token removed before this page takes another page's switch is gone from Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
await saveState();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. That switch puts DAI away under Ethereum Mainnet. This page has not
// taken the switch yet. A switch to Sepolia in this page's Settings keeps DAI
// on Ethereum Mainnet's list.
test("a token another page tracked before its own switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// This page switches to Sepolia. Another popup page has not taken that switch,
// and its save adds a token to Ethereum Mainnet's list. This page switches back
// and tracks that token.
test("a token another page added on Ethereum Mainnet is tracked when this page switches back there", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const newToken = {
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
};
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [MAINNET_TOKEN, newToken] },
});
await chooseNetwork(boot, "mainnet");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN, newToken]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
// popup page has not taken that switch, and its save removes DAI from Ethereum
// Mainnet's list. This page switches back and does not track DAI again.
test("a token another page removed on Ethereum Mainnet stays removed when this page switches back there", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [] },
});
await chooseNetwork(boot, "mainnet");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
fraudContracts: [NEW_TOKEN_ADDRESS],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.fraudContracts).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while
// this page's switch to Sepolia waits for the lock. Another popup page holds
// the lock and stores its own switch to Sepolia, with DAI tracked on Ethereum
// Mainnet. This page's switch keeps DAI and the new token on Ethereum Mainnet's
// list.
test("a token added while a switch in Settings waits for another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// Another popup page holds the lock and stores its switch to Sepolia, with DAI
// tracked on Ethereum Mainnet. This page waits for the lock to take that
// switch. Meanwhile, the contract lookup on SettingsAddToken answers on
// Ethereum Mainnet. When this page takes that switch, DAI and the new token
// stay on Ethereum Mainnet's list.
test("a token added while this page waits to take another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.settle();
releaseLock();
await lockHeld;
await adding;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// This page tracks DAI on Ethereum Mainnet. Another popup page holds the lock
// and stores its switch to Sepolia, with DAI on Ethereum Mainnet's list. This
// page waits for the lock to take that switch. Meanwhile, the user removes DAI,
// as the `[x]` on a tracked token in Settings does. When this page takes that
// switch, DAI is gone from Ethereum Mainnet's list.
test("a token removed while this page waits to take another page's switch is gone from Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
const removing = saveState();
releaseLock();
await lockHeld;
await removing;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
+202
View File
@@ -0,0 +1,202 @@
// TransactionDetail shows only details that Blockscout gave for the
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
//
// The screen asks Blockscout for its transaction and does not wait for the
// answer. So the user can press Back and open another transaction before
// Blockscout answers for the first. The first answer must then fill nothing on
// the second transaction's screen. An answer that lands while Settings is up
// must still fill TransactionDetail.
//
// Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
// opens it: the current screen is pushed, then transactionDetail.show() draws
// the transaction.
"use strict";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
// network with `chainId`.
function historyEntry(hash, chainId, symbol) {
return {
hash,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId,
};
}
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
// A transaction on Sepolia with the same hash as FIRST.
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
// The URL that asks Blockscout for `entry`, on the network it was sent on.
function requestUrl(entry) {
const blockscout =
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
return blockscout + "/transactions/" + entry.hash;
}
// What Blockscout reports for each transaction, by the URL that asks for it.
const ANSWERS = {
[requestUrl(FIRST)]: {
block_number: 21000001,
nonce: 4441,
fee: { value: "21000000000000" },
raw_input: "0x11111111",
method: "firstMethod",
},
[requestUrl(SECOND)]: {
block_number: 21000002,
nonce: 4442,
fee: { value: "42000000000000" },
raw_input: "0x22222222",
method: "secondMethod",
},
[requestUrl(FIRST_ON_SEPOLIA)]: {
block_number: 21000003,
nonce: 4443,
fee: { value: "63000000000000" },
raw_input: "0x33333333",
method: "sepoliaMethod",
},
};
// The text each answer puts on screen: its block, nonce, fee, method name and
// raw data. No text from one answer appears in another.
const FIRST_SHOWN = [
"21000001",
"4441",
"0.000021 ETH",
"firstMethod",
"0x11111111",
];
const SECOND_SHOWN = [
"21000002",
"4442",
"0.000042 ETH",
"secondMethod",
"0x22222222",
];
const FIRST_ON_SEPOLIA_SHOWN = [
"21000003",
"4443",
"0.000063 SepoliaETH",
"sepoliaMethod",
"0x33333333",
];
// Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) {
return [
"tx-detail-block",
"tx-detail-nonce",
"tx-detail-fee",
"tx-detail-calldata-action",
"tx-detail-rawdata",
]
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
.join("\n");
}
// Opens `entry` on TransactionDetail, as a history screen does.
function open(entry) {
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/transactionDetail").show(entry);
}
// The URLs of every request to Blockscout so far, in order.
function requestedUrls() {
return globalThis.fetch.mock.calls.map(([url]) => url);
}
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
// request at once from ANSWERS, except the request for `held`. Returns the
// page and answer(), which lets the answer for `held` land.
async function bootWithHeldRequest(held) {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
let answer;
const heldAnswer = new Promise((resolve) => {
answer = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
if (url === requestUrl(held)) await heldAnswer;
return { ok: true, status: 200, json: async () => ANSWERS[url] };
});
return { page, answer: () => answer() };
}
// The user opens `first`, whose request to Blockscout is held. Then the user
// presses Back and opens `second`. Then the answer for `first` lands. Returns
// the page.
async function firstAnswerLandsOnSecond(first, second) {
const { page, answer } = await bootWithHeldRequest(first);
open(first);
await page.settle();
await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]);
open(second);
await page.settle();
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
return page;
}
afterEach(() => {
cleanupPopup();
});
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
const drawn = drawnDetails(page);
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
const { page, answer } = await bootWithHeldRequest(FIRST);
open(FIRST);
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
answer();
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["transaction"]);
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
const drawn = drawnDetails(page);
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
});
+89 -7
View File
@@ -3440,6 +3440,82 @@ describe("uniswap decoder", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/685 — Permit2 moves no
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
// with an ETH figure.
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
const MOVED_AMOUNT =
"1000000000000000000 base units (decimals unknown)";
test("to another address names the address", () => {
const result = decodeSteps(
[0x00, 0x02],
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
});
test("into the router names the address", () => {
const result = decodeSteps(
[0x02, 0x00],
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
);
expect(detail(result, "Paid to").value).toBe(
"The router, " +
MOVED_AMOUNT +
" from the sender (later steps may not spend all of it)",
);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: ZERO_ADDR,
address: ZERO_ADDR,
isToken: true,
});
});
// The V4 step's SETTLE names the user as payer and states no
// amount. The step's lines say that the ETH amount is not known.
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
const v4EthExactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
590000000n,
ONE_WETH,
),
encodeV4Settle(ZERO_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x02],
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE
// of ETH from the router's own ETH, whichever payer the SETTLE names,
// and the router holds the transaction's value. On next at 36b2002 a
@@ -4304,10 +4380,9 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
});
// With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing. No step pays anyone, so the
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
// A zero balance check sets neither Token Out nor the minimum. The route
// pays only the WRAP_ETH's WETH, to the router, where it stays.
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[
@@ -4320,9 +4395,16 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
);
});
+60 -5
View File
@@ -688,6 +688,62 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]);
});
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A TAKE the decoder cannot read may pay part of the WETH to the user.
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(
@@ -2820,17 +2876,16 @@ describe("the allow-revert flag", () => {
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
expect(detail(withFlag, "Min. received")).toBeUndefined();
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
+6
View File
@@ -357,6 +357,11 @@ describe("every way to a signature or the private key refuses a defective wallet
});
beforeEach(() => {
// showFlash() starts a timer that clears the message two seconds
// later. Each test here runs on fake timers and drops them in
// afterEach, so that timer never outlives the test.
jest.useFakeTimers();
const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check.
@@ -398,6 +403,7 @@ describe("every way to a signature or the private key refuses a defective wallet
});
afterEach(() => {
jest.useRealTimers();
state.wallets = [];
state.activeAddress = null;
state.selectedWallet = null;