Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76785f4dd |
@@ -35,10 +35,10 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-chrome:
|
e2e-chrome:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Bounds the image build, a cold cache included, and the three Chrome
|
# Bounds the image build, a cold cache included, and both Chrome
|
||||||
# programs, so a hung browser frees the shared runner. README.md
|
# programs, so a hung browser frees the shared runner. README.md
|
||||||
# "In CI" has the measured times.
|
# "In CI" has the measured times.
|
||||||
timeout-minutes: 40
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons enrich-tokens apply-enrichment clean dev
|
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons clean dev
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -111,20 +111,6 @@ vendor-blocklist:
|
|||||||
icons:
|
icons:
|
||||||
@node script/lib/icons.js
|
@node script/lib/icons.js
|
||||||
|
|
||||||
# Fetch a name and a website for every token in src/shared/tokenList.js into
|
|
||||||
# script/lib/token-enrichment.json. Run deliberately, land the diff: the names
|
|
||||||
# come from live data, so a later run can return different ones.
|
|
||||||
enrich-tokens:
|
|
||||||
@node script/lib/enrich-tokens.js
|
|
||||||
|
|
||||||
# Write the names and websites in script/lib/token-enrichment.json into
|
|
||||||
# src/shared/tokenList.js. Commit the result with the data file:
|
|
||||||
# tests/applyEnrichment.test.js fails while the list differs from what this
|
|
||||||
# writes, or while an address in the data or in the script's table of websites
|
|
||||||
# matches no entry in the list.
|
|
||||||
apply-enrichment:
|
|
||||||
@node script/lib/apply-enrichment.js
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@rm -rf dist/ release/
|
@rm -rf dist/ release/
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,4 @@ contradicts either, the originals govern.
|
|||||||
- [ ] "Address" not "account" or "derived key"
|
- [ ] "Address" not "account" or "derived key"
|
||||||
- [ ] "Password" not "encryption key" or "vault passphrase"
|
- [ ] "Password" not "encryption key" or "vault passphrase"
|
||||||
- [ ] Error messages are full sentences
|
- [ ] Error messages are full sentences
|
||||||
- [ ] No competitor mentioned by name in code or documentation. Written
|
- [ ] No competitor mentioned by name in code or documentation
|
||||||
exception: the injected provider in `src/content/inpage.js` sets the flag
|
|
||||||
named after the other wallet to `true`, an interface-compatibility flag
|
|
||||||
many dApps check (README.md, Policies)
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
- `git pull` `next` and cut a branch from it — one branch per issue, named
|
- `git pull` `next` and cut a branch from it — one branch per issue, named
|
||||||
`issue-<N>-<slug>`. Never branch from `main`.
|
`issue-<N>-<slug>`. Never branch from `main`.
|
||||||
- Do the work as one commit whose title ends with ` (closes #N)`.
|
- Do the work as one commit whose title ends with ` (closes #N)`, with the
|
||||||
- Add no entry to Completed Steps. What landed is recorded on the tracker and in
|
`TODO.md` update in that same commit.
|
||||||
`git log`, where each commit title names the issue it closes.
|
- Move Next Step to the top of Completed Steps; move the top item of Future
|
||||||
|
Steps into Next Step.
|
||||||
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
|
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
|
||||||
`main` may not.
|
`main` may not.
|
||||||
- Rebase onto current `next` immediately before pushing — other branches land on
|
- Rebase onto current `next` immediately before pushing — other branches land on
|
||||||
@@ -22,149 +23,28 @@
|
|||||||
|
|
||||||
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
|
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
|
||||||
milestone is in flight on `next`; its `next` -> `main` PR is
|
milestone is in flight on `next`; its `next` -> `main` PR is
|
||||||
[#388](https://git.eeqj.de/sneak/AutistMask/pulls/388). `make build` produces
|
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
|
||||||
`dist/chrome/` and `dist/firefox/` with `DEBUG` compiled off, and checks them
|
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
|
||||||
against the build's own receipt to hold exactly the regular files and symlinks
|
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
|
||||||
that build emitted.
|
hold exactly the regular files and symlinks that build emitted, with `DEBUG`
|
||||||
|
compiled off.
|
||||||
|
|
||||||
The backlog lives on the
|
The backlog lives on the
|
||||||
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
||||||
authoritative; this file does not duplicate it. Full policy file set present.
|
authoritative; this file does not duplicate it. Full policy file set present.
|
||||||
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
||||||
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which runs the
|
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
|
||||||
tests, static analysis and the formatting check, and `.gitea/workflows/e2e.yml`
|
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
|
||||||
runs both of them on every push.
|
of them on every push.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Cut 1.0.0 once the
|
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
||||||
[1.0.0 milestone](https://git.eeqj.de/sneak/AutistMask/milestone/6) is empty,
|
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
|
||||||
then continue tagging as milestones land.
|
but the review is broader than any of them.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-08: The browser suites no longer read a screen before the page has
|
|
||||||
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
|
|
||||||
for a screen used to pass as soon as the element laid out, which every view
|
|
||||||
does until the page's stylesheet has applied, so an approval test could read
|
|
||||||
the prompt's fields before the page's script had filled them. `visible()` in
|
|
||||||
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
|
|
||||||
now also wait for the page to finish loading and for neither the element nor
|
|
||||||
anything around it to carry the `hidden` class that `showView()` puts on every
|
|
||||||
view but the current one. No caller changed.
|
|
||||||
|
|
||||||
- 2026-10-08: Switching the network in Settings now tells open pages
|
|
||||||
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
|
|
||||||
saved, Settings asks the background to send `chainChanged` with the new chain
|
|
||||||
id to every open tab, through the same function an approved site request uses.
|
|
||||||
Choosing the network already active changes nothing and sends nothing. Only
|
|
||||||
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
|
|
||||||
drives the real Settings view against the background, and the Chrome suite
|
|
||||||
checks that the test page hears both switches.
|
|
||||||
|
|
||||||
- 2026-10-07: A site can no longer switch the wallet's network by itself
|
|
||||||
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
|
|
||||||
`wallet_switchEthereumChain` request for the other supported network opens a
|
|
||||||
prompt in its own window, through the same approval machinery as the
|
|
||||||
transaction and signature prompts, naming the site, the current network and
|
|
||||||
the requested one. The network, its endpoints, the balances and the caches
|
|
||||||
change, and `chainChanged` is sent, only when the user approves it; rejecting
|
|
||||||
or closing the prompt answers 4001. One such prompt per site at a time. The
|
|
||||||
approval window no longer shows the connection prompt while it waits for the
|
|
||||||
background to describe the approval, because that prompt's "Allow" answers on
|
|
||||||
the same port as the new one; `tests/approvalWindow.test.js` checks that it
|
|
||||||
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
|
|
||||||
suites drive the prompt.
|
|
||||||
|
|
||||||
- 2026-10-07: Two contradictions between the documents and the code are resolved
|
|
||||||
as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The
|
|
||||||
README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
|
|
||||||
scope and other chains out of it. The injected provider's flag named after the
|
|
||||||
other wallet stays, as an interface-compatibility flag many dApps check, and
|
|
||||||
is written down as an exception to the rule against naming competitors in the
|
|
||||||
README's Policies section, in `RULES.md` and in a comment beside it in
|
|
||||||
`src/content/inpage.js`. `script/check-censored` already allows that flag only
|
|
||||||
in that file and its built copies, so it is unchanged.
|
|
||||||
|
|
||||||
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
|
|
||||||
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
|
|
||||||
check that every swept field is driven both truthy and falsy counts only
|
|
||||||
`hostile` and `falsy` values, which the sweep drives onto every restorable
|
|
||||||
view, and no longer a `hostileRestore` value, which reaches only the views its
|
|
||||||
entry names; the stale index 5 moves into `hostile` for `selectedWallet` and
|
|
||||||
`selectedAddress`, as the one value of either still truthy after the floor.
|
|
||||||
Each `hostileRestore` entry declares whether its boot lands on its view or
|
|
||||||
falls back to Home, and is held to it. The limits that remain, fields that
|
|
||||||
share a slot on one boot and anything no stored record reaches by itself, are
|
|
||||||
restated as built in the file's header and the README, which now also say
|
|
||||||
which boots are held to where the popup lands and that a healthy profile is
|
|
||||||
booted onto every restorable view.
|
|
||||||
|
|
||||||
- 2026-10-07: Every password error in the popup is shown the same way
|
|
||||||
([#493](https://git.eeqj.de/sneak/AutistMask/issues/493)): with `showError()`
|
|
||||||
and `hideError()` in a fixed-height error line below the password field, as
|
|
||||||
the send confirmation and approval screens already did. The add wallet screen
|
|
||||||
showed a missing, short or mismatched password in the flash line, and the
|
|
||||||
private key export, recovery phrase and delete wallet screens each had a line
|
|
||||||
of their own above the field. On the add wallet screen the line sits beside
|
|
||||||
the Import button, so the button stays where it was at 360x600. Each line
|
|
||||||
clears when the screen is shown again and when the password is tried again.
|
|
||||||
The add wallet screen's other messages, such as an invalid recovery phrase, a
|
|
||||||
duplicate wallet and the address scan, stay in the flash line.
|
|
||||||
`tests/passwordErrorLines.test.js` drives all four screens in the popup.
|
|
||||||
|
|
||||||
- 2026-10-07: Pre-1.0 security review of the extension
|
|
||||||
([#383](https://git.eeqj.de/sneak/AutistMask/issues/383)), reading the tree at
|
|
||||||
`99292b9` for key handling, the DEBUG mode policy, and what the background
|
|
||||||
accepts from pages, the configured RPC endpoint and the explorer, with what
|
|
||||||
the approval screens show from it; the site permission model and storage were
|
|
||||||
read as well. Its summary on that issue lists ten findings, each filed as its
|
|
||||||
own issue, and all ten are fixed on `next`; one,
|
|
||||||
[#399](https://git.eeqj.de/sneak/AutistMask/issues/399), put funds at risk.
|
|
||||||
Three decisions it raised are still open with the owner: the Argon2id cost for
|
|
||||||
the vault key ([#401](https://git.eeqj.de/sneak/AutistMask/issues/401)), a
|
|
||||||
connected site switching the network with no prompt
|
|
||||||
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)), and `eth_sign`
|
|
||||||
signing as a personal message
|
|
||||||
([#409](https://git.eeqj.de/sneak/AutistMask/issues/409)). Not covered: the
|
|
||||||
end-to-end suites were not run, the bundled phishing blocklist and token list
|
|
||||||
were not checked entry by entry, `ethers` and `libsodium-wrappers-sumo` were
|
|
||||||
taken as audited, and nothing was tried against a real network with real funds
|
|
||||||
([#385](https://git.eeqj.de/sneak/AutistMask/issues/385)). The planned
|
|
||||||
independent second check of each finding did not run; the findings rest on the
|
|
||||||
reviewer's own reading of the code.
|
|
||||||
|
|
||||||
- 2026-10-07: Stale branches pruned from `origin`
|
|
||||||
([#167](https://git.eeqj.de/sneak/AutistMask/issues/167)). The issue
|
|
||||||
classifies each branch it lists, with the evidence. The eighteen still on
|
|
||||||
`origin` that it classifies as landed, or superseded by merged pull requests,
|
|
||||||
were deleted. `feat/message-signing` and `fix/59-transaction-view-ui-policies`
|
|
||||||
had been deleted on 2026-09-09; both landed and stay deleted. Four are kept
|
|
||||||
because their work is not in `next`: `fix/consistent-error-display`,
|
|
||||||
`fix/87-consistent-error-display` and `fix/87-consistent-error-display-v2`,
|
|
||||||
the change for [#87](https://git.eeqj.de/sneak/AutistMask/issues/87) that
|
|
||||||
never landed, as reference for
|
|
||||||
[#493](https://git.eeqj.de/sneak/AutistMask/issues/493); and
|
|
||||||
`chore/token-list-enrichment`, deleted on 2026-09-09 and re-created at
|
|
||||||
`f7a2437`, whose `scripts/` tooling waits on
|
|
||||||
[#495](https://git.eeqj.de/sneak/AutistMask/issues/495).
|
|
||||||
|
|
||||||
- 2026-10-07: The README says that the wallet never clears the clipboard after
|
|
||||||
the private key or the recovery phrase is copied, and why
|
|
||||||
([#492](https://git.eeqj.de/sneak/AutistMask/issues/492)): the clipboard is
|
|
||||||
the user's, clearing it would go against what they expect, and it could
|
|
||||||
destroy something else they copied since. It is under ExportPrivKey, with a
|
|
||||||
line under ShowRecoveryPhrase, and names the warning each password screen
|
|
||||||
actually shows, which says nothing about the clipboard.
|
|
||||||
|
|
||||||
- 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught
|
|
||||||
extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its
|
|
||||||
one entry, Firefox reporting a popup promise that settled after the page
|
|
||||||
unloaded, had lost its cause with
|
|
||||||
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275); the entry and the
|
|
||||||
code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`,
|
|
||||||
and so is the README paragraph that described it.
|
|
||||||
|
|
||||||
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
|
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
|
||||||
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
|
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
|
||||||
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
|
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
|
||||||
@@ -1976,3 +1856,6 @@ then continue tagging as milestones land.
|
|||||||
|
|
||||||
Only work that has no issue of its own belongs here; everything else is on the
|
Only work that has no issue of its own belongs here; everything else is on the
|
||||||
tracker.
|
tracker.
|
||||||
|
|
||||||
|
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
||||||
|
land.
|
||||||
|
|||||||
+49
-115
@@ -57,12 +57,11 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
|
|||||||
|
|
||||||
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
|
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
|
||||||
|
|
||||||
- **No third-party token list APIs.** The balances of the tokens you track are
|
- **No third-party token list APIs.** Token balances come from the same block
|
||||||
read from your RPC endpoint. While "Use Blockscout" is on, the other tokens
|
explorer you configure for transaction history, and the extension ships its
|
||||||
come from the same block explorer you configure for transaction history. The
|
own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
|
||||||
extension ships its own hardcoded list of top ERC-20 contract addresses for
|
detection. Any token you want tracked across all your addresses, you add
|
||||||
symbol-spoofing detection. Any token you want tracked across all your
|
yourself by contract address.
|
||||||
addresses, you add yourself by contract address.
|
|
||||||
|
|
||||||
- **No backend servers operated by the developer.** Nothing is sent to any
|
- **No backend servers operated by the developer.** Nothing is sent to any
|
||||||
server run by AutistMask. Every network destination is listed below.
|
server run by AutistMask. Every network destination is listed below.
|
||||||
@@ -70,13 +69,8 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
AutistMask is a browser extension that runs entirely in your browser. It does
|
AutistMask is a browser extension that runs entirely in your browser. It does
|
||||||
not have a backend server. Out of the box it sends your data only to the
|
not have a backend server. It communicates with five external destinations:
|
||||||
Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending
|
three you configure yourself, and two fixed ones used for scam detection.
|
||||||
token symbols and nothing else.
|
|
||||||
|
|
||||||
Blockscout and Etherscan are sent none of your data until you turn on a setting
|
|
||||||
for each. The settings are "Use Blockscout" and "Check recipients on Etherscan".
|
|
||||||
Both are off in a new install. Welcome and Settings both offer them.
|
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
@@ -89,14 +83,11 @@ verify confirmations. The default is a free public RPC endpoint. You can change
|
|||||||
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
|
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
|
||||||
node.
|
node.
|
||||||
|
|
||||||
When it is contacted:
|
When it is contacted: on every balance refresh (every 10 seconds while the popup
|
||||||
|
is open, every 60 seconds in the background), when you type an ENS name into the
|
||||||
- on every balance refresh (every 10 seconds while the popup is open, every 60
|
Send screen, when a send is prepared and broadcast, while a pending transaction
|
||||||
seconds in the background)
|
is polled for its receipt, and for the reverse ENS lookups used to label
|
||||||
- when you review a send to an ENS name
|
addresses (cached for 12 hours).
|
||||||
- when a send is prepared and broadcast
|
|
||||||
- while a pending transaction is polled for its receipt
|
|
||||||
- for the reverse ENS lookups used to label addresses (cached for 12 hours)
|
|
||||||
|
|
||||||
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
|
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
|
||||||
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
|
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
|
||||||
@@ -105,31 +96,16 @@ addresses are necessarily visible to the RPC provider when querying balances.
|
|||||||
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
|
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
|
||||||
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
|
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
|
||||||
|
|
||||||
Blockscout is an open-source blockchain explorer. AutistMask sends it your data
|
Used to fetch token balances and transaction history. Blockscout is an
|
||||||
only while "Use Blockscout" is on. AutistMask then asks Blockscout for:
|
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
|
||||||
|
balances (including the holder counts used for spam filtering) and your recent
|
||||||
|
transactions and token transfers. You can change this in Settings to a
|
||||||
|
self-hosted Blockscout instance.
|
||||||
|
|
||||||
- your recent transactions and token transfers
|
When it is contacted: on every balance refresh, and whenever a screen showing
|
||||||
- the balances of the tokens you have not tracked, with the holder counts used
|
transaction history is opened.
|
||||||
for spam filtering
|
|
||||||
- the block, fees and calldata of a transaction you open
|
|
||||||
|
|
||||||
You can change the endpoint in Settings to a self-hosted Blockscout instance.
|
What gets sent: your Ethereum addresses (to look up balances and transactions).
|
||||||
While the setting is off, each screen that would show Blockscout's data says
|
|
||||||
instead that the setting is off. Only the tokens you track are listed. When you
|
|
||||||
turn the setting off, every other token leaves the lists at once.
|
|
||||||
|
|
||||||
When it is contacted, while "Use Blockscout" is on:
|
|
||||||
|
|
||||||
- on every balance refresh
|
|
||||||
- whenever a screen showing transaction history is opened
|
|
||||||
- when you open a transaction
|
|
||||||
|
|
||||||
Pressing "Save" beside the Blockscout URL in Settings checks the URL by asking
|
|
||||||
it for `/stats`. That happens whether or not the setting is on. The request
|
|
||||||
sends none of your data.
|
|
||||||
|
|
||||||
What gets sent: your Ethereum addresses (to look up balances and transactions)
|
|
||||||
and the hash of each transaction you open.
|
|
||||||
|
|
||||||
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
|
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
|
||||||
|
|
||||||
@@ -146,21 +122,16 @@ address.
|
|||||||
|
|
||||||
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
||||||
|
|
||||||
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's
|
When you review a send, AutistMask fetches the recipient's public Etherscan
|
||||||
public Etherscan address page when you review a send. AutistMask looks for a
|
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
|
||||||
"Fake_Phishing"/"Phish/Hack" label or a scam warning, and shows a red warning if
|
warning, and shows a red warning if it finds one. This is a plain page fetch
|
||||||
it finds one. This is a plain page fetch with no API key, made by your browser.
|
with no API key, made by your browser. It is best-effort: if it fails, it is
|
||||||
It is best-effort: if it fails, it is silently ignored. This endpoint is not
|
silently ignored. This endpoint is not user-configurable.
|
||||||
user-configurable.
|
|
||||||
|
|
||||||
The setting is off in a new install. While it is off, the confirmation screen
|
When it is contacted: each time you reach the send confirmation screen.
|
||||||
says that no Etherscan check was made.
|
|
||||||
|
|
||||||
When it is contacted: only while "Check recipients on Etherscan" is on, each
|
What gets sent: the recipient address you are about to send to, and your IP
|
||||||
time you reach the send confirmation screen.
|
address. Your own addresses are not sent.
|
||||||
|
|
||||||
What gets sent: the recipient's address and nothing else. As with any request,
|
|
||||||
Etherscan sees your IP address.
|
|
||||||
|
|
||||||
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
|
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
|
||||||
contracts) are ordinary links. They contact nothing until you click them.
|
contracts) are ordinary links. They contact nothing until you click them.
|
||||||
@@ -208,18 +179,12 @@ password.
|
|||||||
3. Click "Load Temporary Add-on".
|
3. Click "Load Temporary Add-on".
|
||||||
4. Select `dist/firefox/manifest.json`.
|
4. Select `dist/firefox/manifest.json`.
|
||||||
|
|
||||||
AutistMask does not work in Firefox private windows. If Firefox is set to "Never
|
|
||||||
remember history", every window is private, so AutistMask does not work at all.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Creating a New Wallet
|
### Creating a New Wallet
|
||||||
|
|
||||||
1. Click the AutistMask icon in your browser toolbar.
|
1. Click the AutistMask icon in your browser toolbar.
|
||||||
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
|
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
|
||||||
On first use, the screen with "Add wallet" also offers "Use Blockscout" and
|
|
||||||
"Check recipients on Etherscan", both off. External Services above says what
|
|
||||||
each one does. You can change both later in Settings.
|
|
||||||
3. On the "From Phrase" tab, click the die button to generate a random 12-word
|
3. On the "From Phrase" tab, click the die button to generate a random 12-word
|
||||||
recovery phrase.
|
recovery phrase.
|
||||||
4. **Write down the recovery phrase and store it safely.** Anyone with these
|
4. **Write down the recovery phrase and store it safely.** Anyone with these
|
||||||
@@ -255,14 +220,10 @@ always produce the same sequence of addresses.
|
|||||||
|
|
||||||
### Adding ERC-20 Tokens
|
### Adding ERC-20 Tokens
|
||||||
|
|
||||||
The wallet looks for tokens you hold only while "Use Blockscout" is on. Even
|
Tokens you hold show up automatically only if they are in the extension's
|
||||||
then, a token shows up only if it is in the extension's bundled list of
|
bundled list of well-known tokens or have at least 1,000 holders; everything
|
||||||
well-known tokens or has at least 1,000 holders. Everything else is treated as
|
else is treated as spam and hidden. To track a token explicitly (which also
|
||||||
spam and hidden.
|
shows it at zero balance), add it by contract address:
|
||||||
|
|
||||||
The wallet shows every token you track, whether or not "Use Blockscout" is on.
|
|
||||||
By default a tracked token is shown at zero balance too (see Display in
|
|
||||||
Settings). To track a token, add it by contract address:
|
|
||||||
|
|
||||||
1. Go to an address detail view (click `[info]` on any address).
|
1. Go to an address detail view (click `[info]` on any address).
|
||||||
2. Click "+ Token".
|
2. Click "+ Token".
|
||||||
@@ -276,12 +237,9 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
|
|||||||
## Sending
|
## Sending
|
||||||
|
|
||||||
1. Click "Send" from the home screen or an address detail view.
|
1. Click "Send" from the home screen or an address detail view.
|
||||||
2. Select what to send: ETH, or a token you track that this address holds. While
|
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
||||||
"Use Blockscout" is on, Send also offers other tokens with a balance on this
|
that survives the spam filters).
|
||||||
address that survive the spam filters.
|
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
||||||
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). AutistMask does
|
|
||||||
not look up a name resolved off-chain. For such a name, enter the address
|
|
||||||
instead.
|
|
||||||
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
|
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
|
||||||
decimal places, or your ETH balance minus the amount reserved for the network
|
decimal places, or your ETH balance minus the amount reserved for the network
|
||||||
fee.
|
fee.
|
||||||
@@ -298,13 +256,9 @@ The confirmation screen shows:
|
|||||||
estimate, and below it the larger amount reserved until it confirms. The
|
estimate, and below it the larger amount reserved until it confirms. The
|
||||||
reserve is what the network requires up front and what the balance check gates
|
reserve is what the network requires up front and what the balance check gates
|
||||||
on; the refund of the difference is why the two differ
|
on; the refund of the difference is why the two differ
|
||||||
- **Warnings** if the recipient is:
|
- **Warnings** if the recipient is a contract, a burn address, one of your own
|
||||||
- a contract
|
addresses, on the bundled scam-address list, or labelled as a phisher on
|
||||||
- a burn address
|
Etherscan
|
||||||
- one of your own addresses
|
|
||||||
- on the bundled scam-address list
|
|
||||||
- while "Check recipients on Etherscan" is on, labelled as a phisher on
|
|
||||||
Etherscan
|
|
||||||
|
|
||||||
After reviewing, enter your password and click "Sign & Send". The transaction
|
After reviewing, enter your password and click "Sign & Send". The transaction
|
||||||
will be broadcast to the network and you will see a waiting screen with a timer.
|
will be broadcast to the network and you will see a waiting screen with a timer.
|
||||||
@@ -315,9 +269,8 @@ screen with the transaction hash and an Etherscan link.
|
|||||||
|
|
||||||
You can also send from a token-specific view: on the address detail screen,
|
You can also send from a token-specific view: on the address detail screen,
|
||||||
click any token balance row. This opens a focused view showing only that token's
|
click any token balance row. This opens a focused view showing only that token's
|
||||||
transactions. While "Use Blockscout" is off, the view says instead that no
|
transactions. Clicking "Send" from here pre-selects and locks the token so you
|
||||||
transaction history is shown. Clicking "Send" from here pre-selects and locks
|
cannot accidentally switch to a different one.
|
||||||
the token so you cannot accidentally switch to a different one.
|
|
||||||
|
|
||||||
## Receiving
|
## Receiving
|
||||||
|
|
||||||
@@ -353,21 +306,7 @@ enter your password and click "Confirm" to authorize it. Message and typed-data
|
|||||||
signature requests work the same way, with a "Sign" button, and also require
|
signature requests work the same way, with a "Sign" button, and also require
|
||||||
your password.
|
your password.
|
||||||
|
|
||||||
If a transaction or signature popup closes before you answer, for example when
|
If the requesting site's domain is on the phishing blocklist, all three approval
|
||||||
you click away to copy your password, the request stays open. Closing the popup
|
|
||||||
does not turn the request down. Click the AutistMask icon in your browser
|
|
||||||
toolbar to bring the request back. Until you answer, the icon opens the request
|
|
||||||
instead of your wallet. If several requests are waiting this way, the oldest
|
|
||||||
comes first.
|
|
||||||
|
|
||||||
A connected site can also ask to switch the wallet's network. A popup shows the
|
|
||||||
site's origin, the current network and the requested network. Nothing changes
|
|
||||||
unless you click "Switch"; clicking "Reject" or closing the popup leaves
|
|
||||||
everything as it was. Switching works exactly as it does in Settings, and
|
|
||||||
changes the network for the whole wallet and for every site, not only for the
|
|
||||||
one that asked.
|
|
||||||
|
|
||||||
If the requesting site's domain is on the phishing blocklist, all four approval
|
|
||||||
screens show a red phishing warning before you decide.
|
screens show a red phishing warning before you decide.
|
||||||
|
|
||||||
You can manage site permissions in Settings. Allowed and denied sites can be
|
You can manage site permissions in Settings. Allowed and denied sites can be
|
||||||
@@ -398,12 +337,11 @@ balances unless they are on the bundled known-token list or you added them
|
|||||||
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
|
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
|
||||||
for address poisoning typically have zero. When the explorer reports no holder
|
for address poisoning typically have zero. When the explorer reports no holder
|
||||||
count for a token, or reports something other than a whole number in plain
|
count for a token, or reports something other than a whole number in plain
|
||||||
digits (such as "1,000"), the count is unknown. While "Use Blockscout" is off,
|
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
|
||||||
no holder count is known. An unknown count does not hide a token from your
|
token from your transaction history or the send token list, and it does not get
|
||||||
transaction history or the send token list, and it does not get a token into
|
a token into your balances either: such a token is listed only if it is on the
|
||||||
your balances either: such a token is listed only if it is on the bundled
|
bundled known-token list or you added it yourself. The screen you reach by
|
||||||
known-token list or you added it yourself. The screen you reach by clicking a
|
clicking a token balance shows a "Holders:" line only when the count is known.
|
||||||
token balance shows a "Holders:" line only when the count is known.
|
|
||||||
|
|
||||||
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
||||||
adds the contract address to a local blocklist. Future transactions from that
|
adds the contract address to a local blocklist. Future transactions from that
|
||||||
@@ -435,20 +373,16 @@ see everything unfiltered.
|
|||||||
Click the gear icon on the home screen to access settings:
|
Click the gear icon on the home screen to access settings:
|
||||||
|
|
||||||
- **Wallets**: Your wallets, and "+ Add wallet".
|
- **Wallets**: Your wallets, and "+ Add wallet".
|
||||||
- **Passwords**: Tick "Allow weak passwords" to choose a password shorter than
|
|
||||||
12 characters when you add a wallet.
|
|
||||||
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
|
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
|
||||||
token".
|
token".
|
||||||
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
|
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
|
||||||
timestamps to UTC, and choose the theme (System, Light, or Dark).
|
timestamps to UTC, and choose the theme (System, Light, or Dark).
|
||||||
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
|
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
|
||||||
restores the RPC and Blockscout endpoints you last used on that network, or
|
resets the RPC and Blockscout endpoints to that network's defaults.
|
||||||
that network's defaults if you have not used it before.
|
|
||||||
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
|
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
|
||||||
You can use your own node for maximum privacy.
|
You can use your own node for maximum privacy.
|
||||||
- **Blockscout API**: the "Use Blockscout" checkbox, off until you tick it, and
|
- **Blockscout API**: Change the Blockscout instance used for token balances and
|
||||||
the URL of the Blockscout instance. You can use a self-hosted instance.
|
transaction history. You can use a self-hosted instance.
|
||||||
- **Etherscan**: "Check recipients on Etherscan", off until you tick it.
|
|
||||||
- **Token Spam Protection**: Toggle individual scam filters and set the dust
|
- **Token Spam Protection**: Toggle individual scam filters and set the dust
|
||||||
transaction threshold.
|
transaction threshold.
|
||||||
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
|
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Minimal Ethereum wallet for Firefox",
|
"description": "Minimal Ethereum wallet for Firefox",
|
||||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||||
"incognito": "not_allowed",
|
|
||||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
@@ -28,8 +27,7 @@
|
|||||||
"web_accessible_resources": ["src/content/inpage.js"],
|
"web_accessible_resources": ["src/content/inpage.js"],
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "autistmask@sneak.berlin",
|
"id": "autistmask@sneak.berlin"
|
||||||
"strict_min_version": "115.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,6 @@
|
|||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
"fmt-check": "prettier --check ."
|
"fmt-check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"jest": {
|
|
||||||
"setupFiles": [
|
|
||||||
"<rootDir>/tests/support/navigatorLocks.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@tailwindcss/cli": "4.3.1",
|
"@tailwindcss/cli": "4.3.1",
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
# (src/shared/balances.js), and this contract's on-chain name is that
|
# (src/shared/balances.js), and this contract's on-chain name is that
|
||||||
# string, so censoring the repo cannot stop the wallet displaying it.
|
# string, so censoring the repo cannot stop the wallet displaying it.
|
||||||
# Dropping the entry instead would cost the user MUSD spoof detection.
|
# Dropping the entry instead would cost the user MUSD spoof detection.
|
||||||
# script/lib/token-enrichment.json, the fetched data tokenList.js's names
|
|
||||||
# are written from, carries the same name for the same reason.
|
|
||||||
#
|
#
|
||||||
# Everything else fails, in the working tree and under dist/. The last two are
|
# Everything else fails, in the working tree and under dist/. The last two are
|
||||||
# literals rather than whole files, so they are enforced by counting, and each
|
# literals rather than whole files, so they are enforced by counting, and each
|
||||||
@@ -104,8 +102,8 @@ allowed_literals_for() {
|
|||||||
src/content/inpage.js | dist/*/src/content/inpage.js)
|
src/content/inpage.js | dist/*/src/content/inpage.js)
|
||||||
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
|
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
|
||||||
;;
|
;;
|
||||||
src/shared/tokenList.js | script/lib/token-enrichment.json | \
|
src/shared/tokenList.js | dist/*/src/background/index.js | \
|
||||||
dist/*/src/background/index.js | dist/*/src/popup/index.js)
|
dist/*/src/popup/index.js)
|
||||||
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
|
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ run_yarn() {
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
|
||||||
# the PATH, which may be a version that formats files differently.
|
|
||||||
if [ ! -x node_modules/.bin/prettier ]; then
|
|
||||||
echo "fmt: this repo's prettier is not installed in node_modules/," \
|
|
||||||
"so run script/bootstrap first." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Formatting..."
|
echo "Formatting..."
|
||||||
run_yarn run fmt
|
run_yarn run fmt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,6 @@ run_yarn() {
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
|
||||||
# the PATH, which may be a version that formats files differently.
|
|
||||||
if [ ! -x node_modules/.bin/prettier ]; then
|
|
||||||
echo "fmt-check: this repo's prettier is not installed in" \
|
|
||||||
"node_modules/, so run script/bootstrap first." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Checking formatting..."
|
echo "Checking formatting..."
|
||||||
run_yarn run fmt-check
|
run_yarn run fmt-check
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
// Writes the names and URLs in script/lib/token-enrichment.json, which
|
|
||||||
// script/lib/enrich-tokens.js fetches, into the matching entries of
|
|
||||||
// src/shared/tokenList.js: the name and url an entry already has are replaced,
|
|
||||||
// and an entry without them gets them after its decimals. Where the fetched
|
|
||||||
// data has no URL for a token, EXTRA_URLS below supplies one. An entry the data
|
|
||||||
// does not mention is left as it is. An address in the data or in EXTRA_URLS
|
|
||||||
// that matches no entry in tokenList.js, capitals included, is an error naming
|
|
||||||
// that address. Run by
|
|
||||||
// `make apply-enrichment`; no build runs it. tests/applyEnrichment.test.js
|
|
||||||
// checks that the committed tokenList.js is exactly what this writes from the
|
|
||||||
// committed data.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
|
|
||||||
|
|
||||||
// Project websites by token address, each spelled as tokenList.js spells it. A
|
|
||||||
// token gets its URL here only where the fetched data has none, so an address
|
|
||||||
// the data has a URL for is never used.
|
|
||||||
const EXTRA_URLS = {
|
|
||||||
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "https://weth.io",
|
|
||||||
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "https://tether.to",
|
|
||||||
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": "https://www.bnbchain.org",
|
|
||||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "https://www.circle.com/usdc",
|
|
||||||
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": "https://sky.money",
|
|
||||||
"0x2AF5D2aD76741191D15Dfe7bF6aC92d4Bd912Ca3": "https://www.bitfinex.com",
|
|
||||||
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "https://chain.link",
|
|
||||||
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "https://ethena.fi",
|
|
||||||
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d":
|
|
||||||
"https://www.worldlibertyfinancial.com",
|
|
||||||
"0x6B175474E89094C44Da98b954EedeAC495271d0F": "https://makerdao.com",
|
|
||||||
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8":
|
|
||||||
"https://www.paypal.com/us/digital-wallet/manage-money/crypto/pyusd",
|
|
||||||
"0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE": "https://shibatoken.com",
|
|
||||||
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6":
|
|
||||||
"https://www.worldlibertyfinancial.com",
|
|
||||||
"0xA0b73E1Ff0B80914AB6fe0444E65848C4C34450b": "https://crypto.com",
|
|
||||||
"0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1": "https://ton.org",
|
|
||||||
"0x68749665FF8D2d112Fa859AA293F07A622782F38": "https://gold.tether.to",
|
|
||||||
"0x196C20DA81Fbc324EcdF55501e95Ce9f0bD84d14": "https://polkadot.network",
|
|
||||||
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": "https://uniswap.org",
|
|
||||||
"0x45804880De22913dAFE09f4980848ECE6EcbAf78":
|
|
||||||
"https://www.paxos.com/paxgold",
|
|
||||||
"0x3c3a81e81dc49A522A592e7622A7E711c06bf354": "https://www.mantle.xyz",
|
|
||||||
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": "https://aave.com",
|
|
||||||
"0x75231F58b43240C9718Dd58B4967c5114342a86c": "https://www.okx.com",
|
|
||||||
"0x6982508145454Ce325dDbE47a25d4ec3d2311933": "https://www.pepe.vip",
|
|
||||||
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
|
|
||||||
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
|
|
||||||
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
|
|
||||||
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
|
|
||||||
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
|
|
||||||
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
|
|
||||||
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
|
|
||||||
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
|
|
||||||
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
|
|
||||||
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
|
|
||||||
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
|
|
||||||
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
|
|
||||||
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
|
|
||||||
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
|
|
||||||
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
|
|
||||||
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
|
|
||||||
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
|
|
||||||
"0x0F5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
|
|
||||||
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
|
|
||||||
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
|
|
||||||
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
|
|
||||||
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
|
|
||||||
"https://basicattentiontoken.org",
|
|
||||||
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
|
|
||||||
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
|
|
||||||
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
|
|
||||||
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
|
|
||||||
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
|
|
||||||
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
|
|
||||||
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
|
|
||||||
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
|
|
||||||
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
|
|
||||||
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
|
|
||||||
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
|
|
||||||
"0x5283D291DBCF85356A21bA090E6db59121208b44": "https://blur.io",
|
|
||||||
"0x43Dfc4159D86F3A37A5A4B3D4580b888ad7d4DDd": "https://dydx.exchange",
|
|
||||||
"0xcf0C122c6b73ff809C693DB761e7BaeBe62b6a2E": "https://floki.com",
|
|
||||||
"0xe53EC727dbDEB9E2d5456c3be40cFF031AB40A55": "https://superfarm.com",
|
|
||||||
"0x32353A6C91143bfd6C7d363B546e62a9A2489A20": "https://www.agora.finance",
|
|
||||||
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": "https://www.euro-coin.com",
|
|
||||||
"0xA35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
|
|
||||||
"0x1151CB3d861920e07a38e03eEAd12C32178567F6": "https://bonk.com",
|
|
||||||
"0x090185f2135308BaD17527004364eBcC2D37e5F6": "https://spell.club",
|
|
||||||
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": "https://stargate.finance",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns tokenList, the text of tokenList.js, with the name and url of each
|
|
||||||
// token in enrichment, the parsed data file, written into its entry.
|
|
||||||
function applyEnrichment(tokenList, enrichment) {
|
|
||||||
for (const addr of Object.keys(EXTRA_URLS)) {
|
|
||||||
if (!tokenList.includes(`address: "${addr}"`)) {
|
|
||||||
throw new Error(
|
|
||||||
`No entry in src/shared/tokenList.js has the address ${addr} from EXTRA_URLS.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = tokenList;
|
|
||||||
|
|
||||||
for (const [addr, data] of Object.entries(enrichment)) {
|
|
||||||
// Escape special regex characters in the address
|
|
||||||
const escaped = addr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
|
|
||||||
// Match the token block: { address: "...", symbol: "...", decimals: N, }
|
|
||||||
// and the name: "...", url: "..." after decimals when it has them.
|
|
||||||
// The closing }, may have trailing whitespace
|
|
||||||
const blockRe = new RegExp(
|
|
||||||
`(\\{\\s*address:\\s*"${escaped}",\\s*symbol:\\s*"[^"]+",\\s*decimals:\\s*\\d+)` +
|
|
||||||
`(?:,\\s*name:\\s*"(?:[^"\\\\]|\\\\.)*",\\s*url:\\s*"(?:[^"\\\\]|\\\\.)*")?` +
|
|
||||||
`,?(\\s*\\})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const match = result.match(blockRe);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(
|
|
||||||
`No entry in src/shared/tokenList.js matches the address ${addr} from script/lib/token-enrichment.json.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A URL from EXTRA_URLS only where the fetched data has none.
|
|
||||||
const url = data.url || EXTRA_URLS[addr] || "";
|
|
||||||
|
|
||||||
// Build replacement: original fields + name + url
|
|
||||||
const nameEscaped = data.name
|
|
||||||
.replace(/\\/g, "\\\\")
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
const urlEscaped = url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
||||||
const replacement =
|
|
||||||
match[1] +
|
|
||||||
`,\n name: "${nameEscaped}",` +
|
|
||||||
`\n url: "${urlEscaped}",` +
|
|
||||||
match[2];
|
|
||||||
|
|
||||||
// A function, so a "$" in a name is not read as a replacement pattern.
|
|
||||||
result = result.replace(blockRe, () => replacement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
const enrichment = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(__dirname, "token-enrichment.json"), "utf8"),
|
|
||||||
);
|
|
||||||
const original = fs.readFileSync(TOKEN_LIST, "utf8");
|
|
||||||
fs.writeFileSync(TOKEN_LIST, applyEnrichment(original, enrichment));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { applyEnrichment };
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// Fetches a name and a website for every token in src/shared/tokenList.js and
|
|
||||||
// writes them, keyed by address, to script/lib/token-enrichment.json, which
|
|
||||||
// script/lib/apply-enrichment.js then writes into tokenList.js. Run by
|
|
||||||
// `make enrich-tokens`; no build runs it and nothing here is shipped.
|
|
||||||
//
|
|
||||||
// Names come from CoinGecko's coin list, fetched whole in one request, matched
|
|
||||||
// on the token's Ethereum contract address. A token CoinGecko does not list
|
|
||||||
// gets its symbol as its name. The list is live data with no fixed version, so
|
|
||||||
// a later run can return different names.
|
|
||||||
//
|
|
||||||
// Websites come from the ethereum-lists/tokens repository, one file per token,
|
|
||||||
// read at a pinned commit. Most tokens have no file there and get an empty URL.
|
|
||||||
// A file that is missing is not an error; any other failed request stops the
|
|
||||||
// run, so a network fault cannot quietly empty a URL.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
|
|
||||||
const OUTPUT = path.join(__dirname, "token-enrichment.json");
|
|
||||||
|
|
||||||
const COINGECKO_LIST =
|
|
||||||
"https://api.coingecko.com/api/v3/coins/list?include_platform=true";
|
|
||||||
// ethereum-lists/tokens at its master of 2026-02-27, when the committed
|
|
||||||
// token-enrichment.json was made.
|
|
||||||
const ETHEREUM_LISTS =
|
|
||||||
"https://raw.githubusercontent.com/ethereum-lists/tokens/c3560d499a380a38e94caa7c4968d59aeffde59a/tokens/eth";
|
|
||||||
|
|
||||||
const CONCURRENT = 10;
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const src = fs.readFileSync(TOKEN_LIST, "utf8");
|
|
||||||
|
|
||||||
const tokens = [];
|
|
||||||
const re =
|
|
||||||
/\{\s*address:\s*"(0x[a-fA-F0-9]{40})",\s*symbol:\s*"([^"]+)",\s*decimals:\s*(\d+)/g;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(src)) !== null) {
|
|
||||||
tokens.push({ address: m[1], symbol: m[2], decimals: parseInt(m[3]) });
|
|
||||||
}
|
|
||||||
console.error(`Parsed ${tokens.length} tokens`);
|
|
||||||
|
|
||||||
// --- Names from CoinGecko ---
|
|
||||||
console.error("Fetching CoinGecko coins list...");
|
|
||||||
const listResp = await fetch(COINGECKO_LIST);
|
|
||||||
if (!listResp.ok) throw new Error(`CoinGecko list HTTP ${listResp.status}`);
|
|
||||||
const coins = await listResp.json();
|
|
||||||
|
|
||||||
const addrMap = new Map();
|
|
||||||
for (const coin of coins) {
|
|
||||||
const eth = (coin.platforms?.ethereum || "").toLowerCase().trim();
|
|
||||||
if (eth && eth.startsWith("0x")) {
|
|
||||||
addrMap.set(eth, { name: coin.name, id: coin.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error(`CoinGecko map: ${addrMap.size} Ethereum tokens`);
|
|
||||||
|
|
||||||
const enriched = new Map();
|
|
||||||
let namesMatched = 0;
|
|
||||||
for (const t of tokens) {
|
|
||||||
const info = addrMap.get(t.address.toLowerCase());
|
|
||||||
if (info) namesMatched++;
|
|
||||||
enriched.set(t.address, { name: info?.name || t.symbol, url: "" });
|
|
||||||
}
|
|
||||||
console.error(`Matched ${namesMatched}/${tokens.length} names`);
|
|
||||||
|
|
||||||
// --- Websites from ethereum-lists ---
|
|
||||||
console.error("Fetching URLs from ethereum-lists GitHub...");
|
|
||||||
let urlsFound = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < tokens.length; i += CONCURRENT) {
|
|
||||||
const batch = tokens.slice(i, i + CONCURRENT);
|
|
||||||
await Promise.all(
|
|
||||||
batch.map(async (t) => {
|
|
||||||
const r = await fetch(`${ETHEREUM_LISTS}/${t.address}.json`);
|
|
||||||
if (r.status === 404) return;
|
|
||||||
if (!r.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`ethereum-lists HTTP ${r.status} for ${t.address}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await r.json();
|
|
||||||
const website = (data.website || "").trim();
|
|
||||||
if (website && website !== "https://") {
|
|
||||||
enriched.get(t.address).url = website;
|
|
||||||
urlsFound++;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (i % 100 === 0) {
|
|
||||||
console.error(
|
|
||||||
` GitHub: ${Math.min(i + CONCURRENT, tokens.length)}/${tokens.length}, ${urlsFound} URLs`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
console.error(`Found ${urlsFound} URLs in ethereum-lists`);
|
|
||||||
|
|
||||||
// --- Output, in the layout prettier gives it ---
|
|
||||||
const output = {};
|
|
||||||
for (const [addr, e] of enriched) {
|
|
||||||
output[addr] = { name: e.name, url: e.url };
|
|
||||||
}
|
|
||||||
fs.writeFileSync(OUTPUT, JSON.stringify(output, null, 4) + "\n");
|
|
||||||
console.error(`Done! ${tokens.length} names, ${urlsFound} URLs.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(`enrich-tokens: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
+16
-41
@@ -22,29 +22,13 @@ set -eu
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
# Each run builds, runs and then removes an image under a tag of its own.
|
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
|
||||||
# Under one tag shared by every run, a second clone's build moves the tag
|
|
||||||
# off this run's image, and the image store can then delete that untagged
|
IIDFILE=""
|
||||||
# image before or while this run uses it. The tag is random rather than
|
|
||||||
# $$ because CI jobs share one docker daemon, but each job's container
|
|
||||||
# numbers its processes from scratch.
|
|
||||||
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
|
||||||
NAME="$("$SCRIPT_DIR/projectname")-e2e-chrome"
|
|
||||||
IMAGE="$NAME:$SUFFIX"
|
|
||||||
# Each docker run below uses this container name: docker run --rm returns
|
|
||||||
# only once its container is removed, so no run meets the one before it.
|
|
||||||
CONTAINER="$NAME-$SUFFIX"
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
# After a third Ctrl-C docker's client exits whether or not the
|
if [ -n "$IIDFILE" ]; then
|
||||||
# container has stopped, and a container still running keeps the image
|
rm -f "$IIDFILE"
|
||||||
# in use.
|
|
||||||
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
||||||
docker container rm -f "$CONTAINER" >/dev/null
|
|
||||||
fi
|
|
||||||
# No image to remove when the build failed or was interrupted.
|
|
||||||
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
||||||
docker image rm "$IMAGE" >/dev/null
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,16 +40,22 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
IIDFILE="$(mktemp)"
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
# HUP: the terminal or SSH session running the suite closed.
|
trap 'cleanup; exit 130' INT TERM
|
||||||
trap 'cleanup; exit 130' HUP INT TERM
|
|
||||||
|
|
||||||
echo "Building the Chrome e2e image (extension included)..."
|
echo "Building the Chrome e2e image (extension included)..."
|
||||||
# --no-cache: the image build runs make build and its checks, and a
|
# --no-cache: the image build runs make build and its checks, and a
|
||||||
# cached layer is a check that did not run.
|
# cached layer is a check that did not run.
|
||||||
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
|
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||||
|
-f tests/e2e/Dockerfile .
|
||||||
|
|
||||||
echo "Running e2e suite in the pinned Playwright container..."
|
echo "Running e2e suite in the pinned Playwright container..."
|
||||||
|
# The image is run by ID, not by tag: where two clones of this repo run
|
||||||
|
# the suite at once, the other build can move the tag between this
|
||||||
|
# build and this run, and the suite would then silently test the other
|
||||||
|
# checkout.
|
||||||
|
#
|
||||||
# --ipc=host: Chromium's shared-memory needs more than the default
|
# --ipc=host: Chromium's shared-memory needs more than the default
|
||||||
# 64MB /dev/shm or renderers crash.
|
# 64MB /dev/shm or renderers crash.
|
||||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||||
@@ -85,12 +75,11 @@ main() {
|
|||||||
# probe. The image is pinned by digest, so this can only ever bite
|
# probe. The image is pinned by digest, so this can only ever bite
|
||||||
# on a deliberate bump.
|
# on a deliberate bump.
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--name "$CONTAINER" \
|
|
||||||
--ipc=host \
|
--ipc=host \
|
||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
|
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
|
||||||
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
|
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
|
||||||
"$IMAGE" \
|
"$(cat "$IIDFILE")" \
|
||||||
node tests/e2e/run.js
|
node tests/e2e/run.js
|
||||||
|
|
||||||
# Where chrome.storage.local lives, and what moves it: two unpacked loads
|
# Where chrome.storage.local lives, and what moves it: two unpacked loads
|
||||||
@@ -104,24 +93,10 @@ main() {
|
|||||||
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
|
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
|
||||||
echo "Running the extension-id and storage-partition observations..."
|
echo "Running the extension-id and storage-partition observations..."
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--name "$CONTAINER" \
|
|
||||||
--ipc=host \
|
--ipc=host \
|
||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
"$IMAGE" \
|
"$(cat "$IIDFILE")" \
|
||||||
node tests/e2e/storagePartition.js
|
node tests/e2e/storagePartition.js
|
||||||
|
|
||||||
# Approvals left open for seven minutes in minimized windows, past the
|
|
||||||
# point where Chrome stopped the background worker when nothing kept it
|
|
||||||
# running. Its own program, without Playwright: Playwright keeps DevTools
|
|
||||||
# attached to the worker, and Chrome never stops a worker DevTools is
|
|
||||||
# attached to. It reaches nothing but its own loopback server.
|
|
||||||
echo "Running the approvals left open for seven minutes..."
|
|
||||||
docker run --rm \
|
|
||||||
--name "$CONTAINER" \
|
|
||||||
--ipc=host \
|
|
||||||
-e HOME=/tmp \
|
|
||||||
"$IMAGE" \
|
|
||||||
node tests/e2e/slowApproval.js
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+16
-22
@@ -21,23 +21,13 @@ set -eu
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
# A tag and a container name of this run's own, as in script/test-e2e,
|
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
|
||||||
# which says why.
|
|
||||||
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
IIDFILE=""
|
||||||
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
|
|
||||||
IMAGE="$NAME:$SUFFIX"
|
|
||||||
CONTAINER="$NAME-$SUFFIX"
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
# After a third Ctrl-C docker's client exits whether or not the
|
if [ -n "$IIDFILE" ]; then
|
||||||
# container has stopped, and a container still running keeps the image
|
rm -f "$IIDFILE"
|
||||||
# in use.
|
|
||||||
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
||||||
docker container rm -f "$CONTAINER" >/dev/null
|
|
||||||
fi
|
|
||||||
# No image to remove when the build failed or was interrupted.
|
|
||||||
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
||||||
docker image rm "$IMAGE" >/dev/null
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,16 +39,22 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
IIDFILE="$(mktemp)"
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
# HUP: the terminal or SSH session running the suite closed.
|
trap 'cleanup; exit 130' INT TERM
|
||||||
trap 'cleanup; exit 130' HUP INT TERM
|
|
||||||
|
|
||||||
echo "Building the pinned Firefox e2e image (extension included)..."
|
echo "Building the pinned Firefox e2e image (extension included)..."
|
||||||
# --no-cache: the image build runs make build and its checks, and a
|
# --no-cache: the image build runs make build and its checks, and a
|
||||||
# cached layer is a check that did not run.
|
# cached layer is a check that did not run.
|
||||||
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
|
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||||
|
-f tests/e2e/firefox/Dockerfile .
|
||||||
|
|
||||||
echo "Running the Firefox e2e suite..."
|
echo "Running the Firefox e2e suite..."
|
||||||
|
# The image is run by ID, not by tag: where two clones of this repo run
|
||||||
|
# the suite at once, the other build can move the tag between this
|
||||||
|
# build and this run, and the suite would then silently test the other
|
||||||
|
# checkout.
|
||||||
|
#
|
||||||
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
|
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
|
||||||
# --network none: the suite stubs nothing, so this is what keeps the
|
# --network none: the suite stubs nothing, so this is what keeps the
|
||||||
# run offline and deterministic. The extension swallows its own
|
# run offline and deterministic. The extension swallows its own
|
||||||
@@ -73,11 +69,10 @@ main() {
|
|||||||
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
|
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
|
||||||
# it is cosmetic and headless Firefox runs fine without it.
|
# it is cosmetic and headless Firefox runs fine without it.
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--name "$CONTAINER" \
|
|
||||||
--shm-size=1g \
|
--shm-size=1g \
|
||||||
--network none \
|
--network none \
|
||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
"$IMAGE" \
|
"$(cat "$IIDFILE")" \
|
||||||
node tests/e2e/firefox/run.js dist/firefox
|
node tests/e2e/firefox/run.js dist/firefox
|
||||||
|
|
||||||
# The install/uninstall/re-install property, against the packaged XPI
|
# The install/uninstall/re-install property, against the packaged XPI
|
||||||
@@ -87,11 +82,10 @@ main() {
|
|||||||
# and the suite above shares one session throughout.
|
# and the suite above shares one session throughout.
|
||||||
echo "Running the Firefox re-install suite against the packaged XPI..."
|
echo "Running the Firefox re-install suite against the packaged XPI..."
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--name "$CONTAINER" \
|
|
||||||
--shm-size=1g \
|
--shm-size=1g \
|
||||||
--network none \
|
--network none \
|
||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
"$IMAGE" \
|
"$(cat "$IIDFILE")" \
|
||||||
node tests/e2e/firefox/reinstall.js
|
node tests/e2e/firefox/reinstall.js
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+142
-371
@@ -17,20 +17,12 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
|
|||||||
// the same prohibition reported early, not the guarantee.
|
// the same prohibition reported early, not the guarantee.
|
||||||
const { getState, updateState } = require("./state");
|
const { getState, updateState } = require("./state");
|
||||||
const { StateUnusableError } = require("../shared/stateSchema");
|
const { StateUnusableError } = require("../shared/stateSchema");
|
||||||
const {
|
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||||
refreshBalances,
|
|
||||||
dropUntrackedTokens,
|
|
||||||
getProvider,
|
|
||||||
} = require("../shared/balances");
|
|
||||||
const { debugFetch, log } = require("../shared/log");
|
const { debugFetch, log } = require("../shared/log");
|
||||||
const {
|
const {
|
||||||
verifySignedTx,
|
verifySignedTx,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
signedPrimaryType,
|
|
||||||
typedDataMessageRefusal,
|
|
||||||
typedDataChainRefusal,
|
|
||||||
failureIsRetryable,
|
failureIsRetryable,
|
||||||
nodeError,
|
|
||||||
describeTxFailure,
|
describeTxFailure,
|
||||||
sameAddress,
|
sameAddress,
|
||||||
ApprovalMismatchError,
|
ApprovalMismatchError,
|
||||||
@@ -51,11 +43,7 @@ const {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
actionApi,
|
actionApi,
|
||||||
getPlatformInfo,
|
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
sessionStorageGet,
|
|
||||||
sessionStorageRemove,
|
|
||||||
sessionStorageSet,
|
|
||||||
tabsQuery,
|
tabsQuery,
|
||||||
tabsSendMessage,
|
tabsSendMessage,
|
||||||
windowsApi,
|
windowsApi,
|
||||||
@@ -68,62 +56,16 @@ const runtime = runtimeApi();
|
|||||||
const windowsNs = windowsApi();
|
const windowsNs = windowsApi();
|
||||||
const actionNs = actionApi();
|
const actionNs = actionApi();
|
||||||
|
|
||||||
// Connections the user allowed without "Remember my choice for this site".
|
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
||||||
// Each is one item in the browser's session storage, key
|
|
||||||
// origin + ":" + address and value true, and session storage holds nothing
|
|
||||||
// else. Session storage outlives this worker being stopped when idle and
|
|
||||||
// started again, so such a connection lasts until it is ended below or the
|
|
||||||
// browser closes. One item per connection means that making one is a single
|
|
||||||
// write, and two never overwrite each other.
|
|
||||||
//
|
//
|
||||||
// A site is its full origin (scheme://host[:port]), here and in the
|
// A site is its full origin (scheme://host[:port]), here and in the
|
||||||
// remembered allowedSites/deniedSites lists alike: a grant to
|
// remembered allowedSites/deniedSites lists alike: a grant to
|
||||||
// https://dapp.example says nothing about http://dapp.example or another port.
|
// https://dapp.example says nothing about http://dapp.example or another port.
|
||||||
// An origin can carry a port, so the address is what follows the key's last
|
const connectedSites = {};
|
||||||
// colon and the origin is what precedes it.
|
|
||||||
async function isConnected(origin, address) {
|
|
||||||
const key = origin + ":" + address;
|
|
||||||
const items = await sessionStorageGet(key);
|
|
||||||
return items[key] === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// End every connection allowed without "Remember" whose key `ends` returns
|
|
||||||
// true for.
|
|
||||||
async function endConnections(ends) {
|
|
||||||
const keys = Object.keys(await sessionStorageGet(null)).filter(ends);
|
|
||||||
if (keys.length > 0) await sessionStorageRemove(keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending approval requests: { id: { origin, resolve } }
|
// Pending approval requests: { id: { origin, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
|
|
||||||
// An approval, and the site's request waiting on it, exist only in this
|
|
||||||
// worker's memory. Left alone, Chrome stopped the worker about six minutes
|
|
||||||
// after the site's request while the user was still deciding, and the approval
|
|
||||||
// was lost with it. Chrome counts an extension call as activity, so while any
|
|
||||||
// approval is pending this worker makes one every 20 seconds, inside Chrome's
|
|
||||||
// 30-second idle limit. It is the worker's own timer, so it runs whether or not
|
|
||||||
// the approval window is visible, and it stops itself once nothing is pending.
|
|
||||||
let keepRunningTimer = null;
|
|
||||||
|
|
||||||
// Add an approval waiting for the user, and start the timer above if it is not
|
|
||||||
// running. This is the ONLY place an approval is added, as settleApproval() is
|
|
||||||
// the only place one is removed, so every kind of approval keeps the worker
|
|
||||||
// running while it waits.
|
|
||||||
function addPendingApproval(approval) {
|
|
||||||
pendingApprovals[approval.id] = approval;
|
|
||||||
if (keepRunningTimer) return;
|
|
||||||
keepRunningTimer = setInterval(() => {
|
|
||||||
if (Object.keys(pendingApprovals).length === 0) {
|
|
||||||
clearInterval(keepRunningTimer);
|
|
||||||
keepRunningTimer = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only the call matters; its answer is not used.
|
|
||||||
getPlatformInfo().catch(() => {});
|
|
||||||
}, 20000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One transaction approval at a time, wallet-wide.
|
// One transaction approval at a time, wallet-wide.
|
||||||
//
|
//
|
||||||
// The transaction a site asks for is populated before its approval window
|
// The transaction a site asks for is populated before its approval window
|
||||||
@@ -161,9 +103,9 @@ let txApprovalSlot = null;
|
|||||||
const APPROVAL_PENDING_CODE = -32002;
|
const APPROVAL_PENDING_CODE = -32002;
|
||||||
|
|
||||||
// True at every moment this can be sent: the slot is taken immediately before
|
// True at every moment this can be sent: the slot is taken immediately before
|
||||||
// the transaction is populated, so the other request is being prepared, is on
|
// the transaction is populated, so the other request is either being prepared
|
||||||
// screen, or waits with its window closed. It does not claim the other one is
|
// or on screen. It does not claim the other one is displayed yet, because for
|
||||||
// displayed yet, because for the length of one network round trip it is not.
|
// the length of one network round trip it is not.
|
||||||
const TX_APPROVAL_PENDING_MESSAGE =
|
const TX_APPROVAL_PENDING_MESSAGE =
|
||||||
"AutistMask handles one transaction at a time, and another one is" +
|
"AutistMask handles one transaction at a time, and another one is" +
|
||||||
" already in progress, so this one was not sent. Please finish that" +
|
" already in progress, so this one was not sent. Please finish that" +
|
||||||
@@ -195,12 +137,11 @@ function releaseTxApprovalSlotFor(approvalId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One site-connection approval, one sign approval and one network-switch
|
// One site-connection approval and one sign approval per site at a time: a
|
||||||
// approval per site at a time: a page that asks again before the user has
|
// page that asks again before the user has answered is refused with the code
|
||||||
// answered is refused with the code above instead of opening another window,
|
// above instead of opening another window, so it cannot bury the user in
|
||||||
// so it cannot bury the user in prompts. The pending approval itself holds the
|
// prompts. The pending approval itself holds the place, so a caller must test
|
||||||
// place, so a caller must test this and raise its approval with nothing awaited
|
// this and raise its approval with nothing awaited in between.
|
||||||
// in between.
|
|
||||||
function findPendingApproval(origin, type) {
|
function findPendingApproval(origin, type) {
|
||||||
return Object.values(pendingApprovals).find(
|
return Object.values(pendingApprovals).find(
|
||||||
(approval) => approval.origin === origin && approval.type === type,
|
(approval) => approval.origin === origin && approval.type === type,
|
||||||
@@ -299,21 +240,6 @@ function failureError(err) {
|
|||||||
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
|
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The EIP-1193 error for a request that failed, with `message` as the
|
|
||||||
// sentence the page is shown. `rpcError` holds the JSON-RPC code and data the
|
|
||||||
// failure carries, if any: the node's own when the node refused the request,
|
|
||||||
// passed on unchanged so a site can tell a revert from any other failure and
|
|
||||||
// decode its revert data. A failure with no numeric code is the wallet's own,
|
|
||||||
// and is answered with the internal error code above.
|
|
||||||
function requestError(message, rpcError) {
|
|
||||||
if (rpcError && typeof rpcError.code === "number") {
|
|
||||||
const error = { code: rpcError.code, message };
|
|
||||||
if (rpcError.data !== undefined) error.data = rpcError.data;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
return { code: INTERNAL_ERROR_CODE, message };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The active address of a profile snapshot. Pure, and taking the snapshot as
|
// The active address of a profile snapshot. Pure, and taking the snapshot as
|
||||||
// an argument rather than reading storage itself: a handler that has already
|
// an argument rather than reading storage itself: a handler that has already
|
||||||
// read state must not answer "which account is this" from a SECOND, later read
|
// read state must not answer "which account is this" from a SECOND, later read
|
||||||
@@ -359,8 +285,7 @@ function extractHostname(origin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy an RPC call to the Ethereum node. An error the node answers with is
|
// Proxy an RPC call to the Ethereum node
|
||||||
// thrown with the node's own JSON-RPC `code` and `data` on it, for the page.
|
|
||||||
async function proxyRpc(method, params) {
|
async function proxyRpc(method, params) {
|
||||||
const rpcUrl = await getRpcUrl();
|
const rpcUrl = await getRpcUrl();
|
||||||
const resp = await debugFetch(rpcUrl, {
|
const resp = await debugFetch(rpcUrl, {
|
||||||
@@ -375,34 +300,19 @@ async function proxyRpc(method, params) {
|
|||||||
});
|
});
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
if (json.error) {
|
if (json.error) {
|
||||||
const err = new Error(json.error.message || "RPC error");
|
throw new Error(json.error.message || "RPC error");
|
||||||
err.code = json.error.code;
|
|
||||||
err.data = json.error.data;
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
return json.result;
|
return json.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The approval the toolbar popup is set to open, or null while it opens the
|
// The site-connection approval the toolbar popup is set to open, or null while
|
||||||
// wallet. Set only by updateToolbarPopup() and showInToolbarPopup().
|
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
|
||||||
let toolbarPopupApprovalId = null;
|
let toolbarPopupApprovalId = null;
|
||||||
|
|
||||||
// Point the toolbar popup at the oldest pending approval whose window closed,
|
function resetPopupUrl() {
|
||||||
// or at the wallet when there is none. The user can then answer that approval
|
toolbarPopupApprovalId = null;
|
||||||
// from the toolbar popup. pendingApprovals lists approvals in the order they
|
|
||||||
// were added, so the first one found is the oldest. An approval held by a
|
|
||||||
// running attempt is skipped until that attempt ends.
|
|
||||||
function updateToolbarPopup() {
|
|
||||||
const closed = Object.values(pendingApprovals).find(
|
|
||||||
(approval) => approval.windowClosed && !approval.attemptInFlight,
|
|
||||||
);
|
|
||||||
toolbarPopupApprovalId = closed ? closed.id : null;
|
|
||||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||||
actionNs.setPopup({
|
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||||
popup: closed
|
|
||||||
? "src/popup/index.html?approval=" + closed.id
|
|
||||||
: "src/popup/index.html",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,16 +342,14 @@ function settleApproval(id, result, options) {
|
|||||||
// approval stops being alive.
|
// approval stops being alive.
|
||||||
releaseTxApprovalSlotFor(id);
|
releaseTxApprovalSlotFor(id);
|
||||||
approval.resolve(result);
|
approval.resolve(result);
|
||||||
updateToolbarPopup();
|
resetPopupUrl();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a pending approval resolves to when it is given up on rather than
|
// What a pending approval resolves to when it is given up on rather than
|
||||||
// answered: a site-connection or network-switch window was closed, or the
|
// answered: the window was closed, or could not be opened at all. A tx or sign
|
||||||
// window could not be opened at all. An address switch in the wallet gives up
|
// approval answers the requesting page in EIP-1193 shape; a site-connection
|
||||||
// pending approvals too. A tx or sign approval answers the requesting page in
|
// approval answers the connection handler in its own.
|
||||||
// EIP-1193 shape; a site-connection or network-switch approval answers its
|
|
||||||
// handler in the shape the popup's decision has, as a refusal.
|
|
||||||
function abandonedResult(approval, code, message) {
|
function abandonedResult(approval, code, message) {
|
||||||
if (approval.type === "tx" || approval.type === "sign") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
return { error: { code, message } };
|
return { error: { code, message } };
|
||||||
@@ -449,9 +357,8 @@ function abandonedResult(approval, code, message) {
|
|||||||
return { approved: false, remember: false };
|
return { approved: false, remember: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// A site-connection or network-switch window the user closed without answering
|
// A window the user closed without answering is a refusal by the user, which
|
||||||
// is a refusal by the user, which is 4001 and the wording every other rejection
|
// is 4001 and the wording every other rejection path already uses.
|
||||||
// path already uses.
|
|
||||||
const APPROVAL_REJECTED_CODE = 4001;
|
const APPROVAL_REJECTED_CODE = 4001;
|
||||||
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
|
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
|
||||||
|
|
||||||
@@ -479,16 +386,31 @@ const APPROVAL_WINDOW_FAILED_MESSAGE =
|
|||||||
function claimApproval(approval) {
|
function claimApproval(approval) {
|
||||||
if (approval.attemptInFlight) return false;
|
if (approval.attemptInFlight) return false;
|
||||||
approval.attemptInFlight = true;
|
approval.attemptInFlight = true;
|
||||||
updateToolbarPopup();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release an approval whose attempt failed in a way the user can retry.
|
// Release an approval whose attempt failed in a way the user can retry.
|
||||||
// Nothing was broadcast, so the next attempt may claim it. If the user closed
|
// Nothing was broadcast, so the next attempt may claim it.
|
||||||
// its window while the attempt ran, the toolbar popup opens it for that retry.
|
//
|
||||||
|
// Unless the window it would be retried in is already gone. The user closed it
|
||||||
|
// while the attempt was running and settleApproval() declined then, correctly,
|
||||||
|
// because the attempt still owned the approval; the attempt has now failed, so
|
||||||
|
// nothing owns it and nothing can reach it. Left standing it would hold the
|
||||||
|
// requesting page's promise open forever and, with it, the transaction
|
||||||
|
// approval slot. It is settled here as the rejection the closed window
|
||||||
|
// already meant.
|
||||||
function releaseApproval(approval) {
|
function releaseApproval(approval) {
|
||||||
approval.attemptInFlight = false;
|
approval.attemptInFlight = false;
|
||||||
updateToolbarPopup();
|
if (approval.windowClosed) {
|
||||||
|
settleApproval(
|
||||||
|
approval.id,
|
||||||
|
abandonedResult(
|
||||||
|
approval,
|
||||||
|
APPROVAL_REJECTED_CODE,
|
||||||
|
APPROVAL_REJECTED_MESSAGE,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open approval in a separate popup window, unless it is no longer pending.
|
// Open approval in a separate popup window, unless it is no longer pending.
|
||||||
@@ -498,10 +420,7 @@ function releaseApproval(approval) {
|
|||||||
// the result on the floor, so a rejection here would be unhandled.
|
// the result on the floor, so a rejection here would be unhandled.
|
||||||
async function openApprovalWindow(id) {
|
async function openApprovalWindow(id) {
|
||||||
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
||||||
// As wide as the page: body in src/popup/styles/main.css. The browser
|
const popupWidth = 360;
|
||||||
// counts the window's frame in this width; the page itself adds back
|
|
||||||
// whatever the frame takes (src/popup/index.js).
|
|
||||||
const popupWidth = 396;
|
|
||||||
const popupHeight = 600;
|
const popupHeight = 600;
|
||||||
|
|
||||||
let currentWin = null;
|
let currentWin = null;
|
||||||
@@ -521,7 +440,7 @@ async function openApprovalWindow(id) {
|
|||||||
// Centred on a browser window only. The last focused window can be
|
// Centred on a browser window only. The last focused window can be
|
||||||
// another approval window still open, and centring on one can give a
|
// another approval window still open, and centring on one can give a
|
||||||
// position the browser refuses ("Bounds must be at least 50% within
|
// position the browser refuses ("Bounds must be at least 50% within
|
||||||
// visible screen space"): headless Chrome reports this 396x600 popup as
|
// visible screen space"): headless Chrome reports this 360x600 popup as
|
||||||
// 1280x720. The request then failed with no window at all. Over a popup,
|
// 1280x720. The request then failed with no window at all. Over a popup,
|
||||||
// the browser picks the position.
|
// the browser picks the position.
|
||||||
if (currentWin && currentWin.type === "normal") {
|
if (currentWin && currentWin.type === "normal") {
|
||||||
@@ -592,7 +511,7 @@ async function openApprovalWindow(id) {
|
|||||||
function requestApproval(origin) {
|
function requestApproval(origin) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
addPendingApproval({ id, origin, resolve, type: "site" });
|
pendingApprovals[id] = { id, origin, resolve, type: "site" };
|
||||||
|
|
||||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||||
showInToolbarPopup(id);
|
showInToolbarPopup(id);
|
||||||
@@ -635,14 +554,14 @@ function showInToolbarPopup(id) {
|
|||||||
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
addPendingApproval({
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
type: "tx",
|
type: "tx",
|
||||||
});
|
};
|
||||||
if (slot) slot.approvalId = id;
|
if (slot) slot.approvalId = id;
|
||||||
|
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
@@ -656,34 +575,14 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
|||||||
function requestSignApproval(origin, signParams, approvedFrom) {
|
function requestSignApproval(origin, signParams, approvedFrom) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
addPendingApproval({
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
signParams,
|
signParams,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
type: "sign",
|
type: "sign",
|
||||||
});
|
};
|
||||||
|
|
||||||
openApprovalWindow(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open a network-switch approval popup and return a promise that resolves with
|
|
||||||
// { approved }. Opened in a window, as tx and sign approvals are, because a
|
|
||||||
// site's request is not a user gesture. The popup answers on the approval port,
|
|
||||||
// as a site-connection approval does.
|
|
||||||
function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
addPendingApproval({
|
|
||||||
id,
|
|
||||||
origin,
|
|
||||||
currentNetworkId,
|
|
||||||
requestedNetworkId,
|
|
||||||
resolve,
|
|
||||||
type: "network",
|
|
||||||
});
|
|
||||||
|
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
});
|
});
|
||||||
@@ -698,8 +597,8 @@ function isExtensionSender(sender) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The approval popup's port: it carries the user's decision on a
|
// The approval popup's port: it carries the user's decision on a
|
||||||
// site-connection or network-switch approval, and its disconnect is how that
|
// site-connection approval, and its disconnect is how that approval learns the
|
||||||
// approval learns the popup closed without one.
|
// popup closed without one.
|
||||||
//
|
//
|
||||||
// The decision travels this port rather than a one-off runtime.sendMessage()
|
// The decision travels this port rather than a one-off runtime.sendMessage()
|
||||||
// for exactly one reason: the port is also what the popup's window.close()
|
// for exactly one reason: the port is also what the popup's window.close()
|
||||||
@@ -711,7 +610,8 @@ function isExtensionSender(sender) {
|
|||||||
// had refused.
|
// had refused.
|
||||||
//
|
//
|
||||||
// TX and sign approvals do not decide here. They stay pending across a
|
// TX and sign approvals do not decide here. They stay pending across a
|
||||||
// disconnect, and across their window closing, until the user answers them.
|
// disconnect — the user can reopen the toolbar popup — and are rejected by the
|
||||||
|
// windows.onRemoved listener below.
|
||||||
runtime.onConnect.addListener((port) => {
|
runtime.onConnect.addListener((port) => {
|
||||||
if (port.name.startsWith("approval:")) {
|
if (port.name.startsWith("approval:")) {
|
||||||
const id = port.name.split(":")[1];
|
const id = port.name.split(":")[1];
|
||||||
@@ -739,7 +639,7 @@ runtime.onConnect.addListener((port) => {
|
|||||||
const approval = pendingApprovals[id];
|
const approval = pendingApprovals[id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
if (approval.type === "tx" || approval.type === "sign") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
// It stays pending until the user answers it.
|
// Keep pending — user can reopen the toolbar popup
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settleApproval(id, { approved: false, remember: false });
|
settleApproval(id, { approved: false, remember: false });
|
||||||
@@ -769,7 +669,7 @@ async function handleConnectionRequest(origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) {
|
if (!activeAddress) {
|
||||||
return { error: { code: 4100, message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
@@ -785,10 +685,10 @@ async function handleConnectionRequest(origin) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check allowed list or a connection allowed without "Remember"
|
// Check allowed list or in-memory connected
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(origin) ||
|
||||||
(await isConnected(origin, activeAddress))
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
}
|
}
|
||||||
@@ -825,7 +725,7 @@ async function handleConnectionRequest(origin) {
|
|||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
||||||
} else {
|
} else {
|
||||||
await sessionStorageSet({ [origin + ":" + activeAddress]: true });
|
connectedSites[origin + ":" + activeAddress] = true;
|
||||||
}
|
}
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
} else {
|
} else {
|
||||||
@@ -879,7 +779,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(origin) ||
|
||||||
(await isConnected(origin, activeAddress))
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
}
|
}
|
||||||
@@ -906,16 +806,12 @@ async function handleRpc(method, params, origin) {
|
|||||||
// tab is served from, so a page the user never connected to must
|
// tab is served from, so a page the user never connected to must
|
||||||
// not be able to do it. Ungated, any page could clear the
|
// not be able to do it. Ungated, any page could clear the
|
||||||
// [TESTNET] banner under a user who believed they were on Sepolia.
|
// [TESTNET] banner under a user who believed they were on Sepolia.
|
||||||
//
|
|
||||||
// A connected site does not switch it either: only the user does,
|
|
||||||
// by approving the request on the prompt below
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/408).
|
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!(await isConnected(origin, activeAddress))
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -931,27 +827,6 @@ async function handleRpc(method, params, origin) {
|
|||||||
}
|
}
|
||||||
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
|
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
|
||||||
const target = networkByChainId(chainId);
|
const target = networkByChainId(chainId);
|
||||||
if (findPendingApproval(origin, "network")) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const decision = await requestNetworkApproval(
|
|
||||||
origin,
|
|
||||||
s.networkId,
|
|
||||||
target.id,
|
|
||||||
);
|
|
||||||
if (!decision.approved) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_REJECTED_CODE,
|
|
||||||
message: APPROVAL_REJECTED_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Read-modify-write against storage. The old path went through
|
// Read-modify-write against storage. The old path went through
|
||||||
// onChainSwitch(), which mutates the singleton and then persists
|
// onChainSwitch(), which mutates the singleton and then persists
|
||||||
// every field of it — on an unloaded worker that wrote empty
|
// every field of it — on an unloaded worker that wrote empty
|
||||||
@@ -1008,10 +883,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const hasPermission =
|
const isConnected =
|
||||||
allowed.includes(origin) ||
|
allowed.includes(origin) ||
|
||||||
(await isConnected(origin, activeAddress));
|
connectedSites[origin + ":" + activeAddress];
|
||||||
if (!hasPermission || !activeAddress) {
|
if (!isConnected || !activeAddress) {
|
||||||
return { result: [] };
|
return { result: [] };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1033,12 +908,12 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { code: 4100, message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!(await isConnected(origin, activeAddress))
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -1062,8 +937,9 @@ async function handleRpc(method, params, origin) {
|
|||||||
|
|
||||||
if (method === "eth_sign") {
|
if (method === "eth_sign") {
|
||||||
signParams.dangerWarning =
|
signParams.dangerWarning =
|
||||||
"This site used the older eth_sign method. " +
|
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
|
||||||
"The data will be signed as a personal message.";
|
"This can be used to sign transactions that drain your funds. " +
|
||||||
|
"Only proceed if you fully understand what you are signing.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (findPendingApproval(origin, "sign")) {
|
if (findPendingApproval(origin, "sign")) {
|
||||||
@@ -1087,12 +963,12 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { code: 4100, message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!(await isConnected(origin, activeAddress))
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -1134,13 +1010,12 @@ async function handleRpc(method, params, origin) {
|
|||||||
const result = await proxyRpc(method, params);
|
const result = await proxyRpc(method, params);
|
||||||
return { result };
|
return { result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A node that answered with an error is reported as itself, with
|
// A node that answered with an error is reported as itself. The
|
||||||
// its own code and data. The wallet being unable to read its own
|
// wallet being unable to read its own profile is not that, and it
|
||||||
// profile is not that, and it must not be answered as an ordinary
|
// must not be flattened into a message with no code: it goes back
|
||||||
// failure: it goes back to the dispatcher, which has the one
|
// to the dispatcher, which has the one answer for it.
|
||||||
// answer for it.
|
|
||||||
if (e instanceof StateUnusableError) throw e;
|
if (e instanceof StateUnusableError) throw e;
|
||||||
return { error: requestError(e.message, e) };
|
return { error: { message: e.message } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,14 +1031,12 @@ async function handleRpc(method, params, origin) {
|
|||||||
async function handleSendTransaction(params, origin) {
|
async function handleSendTransaction(params, origin) {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) {
|
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||||
return { error: { code: 4100, message: "No accounts available" } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!(await isConnected(origin, activeAddress))
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1090,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
txParams,
|
txParams,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: requestError(e.message, e) };
|
return { error: { message: e.message } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Population is a network round trip, and the user can switch address
|
// Population is a network round trip, and the user can switch address
|
||||||
@@ -1227,9 +1100,10 @@ async function handleSendTransaction(params, origin) {
|
|||||||
// while the page is still waiting and nothing has been displayed.
|
// while the page is still waiting and nothing has been displayed.
|
||||||
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
||||||
return {
|
return {
|
||||||
error: requestError(
|
error: {
|
||||||
"The active address changed while this transaction was being prepared, so it was not sent.",
|
message:
|
||||||
),
|
"The active address changed while this transaction was being prepared, so it was not sent.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,6 +1149,10 @@ async function broadcastChainChanged(chainId) {
|
|||||||
|
|
||||||
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
||||||
async function broadcastAccountsChanged() {
|
async function broadcastAccountsChanged() {
|
||||||
|
// Clear non-remembered approvals on address switch
|
||||||
|
for (const key of Object.keys(connectedSites)) {
|
||||||
|
delete connectedSites[key];
|
||||||
|
}
|
||||||
// Reject and close any pending approval popups so they don't hang. An
|
// Reject and close any pending approval popups so they don't hang. An
|
||||||
// approval an attempt has already claimed is left alone entirely: it is
|
// approval an attempt has already claimed is left alone entirely: it is
|
||||||
// being signed and broadcast right now, and neither rejecting it to the
|
// being signed and broadcast right now, and neither rejecting it to the
|
||||||
@@ -1292,11 +1170,7 @@ async function broadcastAccountsChanged() {
|
|||||||
windowsRemove(approval.windowId).catch(() => {});
|
windowsRemove(approval.windowId).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateToolbarPopup();
|
resetPopupUrl();
|
||||||
// An address switch ends every connection allowed without "Remember". This
|
|
||||||
// waits on storage, so it comes after the pending approvals above, which
|
|
||||||
// are still rejected the moment the switch arrives.
|
|
||||||
await endConnections(() => true);
|
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
||||||
@@ -1307,24 +1181,17 @@ async function broadcastAccountsChanged() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
// The tab's URL is all the background knows of the page in it, and a
|
|
||||||
// page at a site's URL may have another origin: a page its server
|
|
||||||
// sandboxes with a Content-Security-Policy has the origin "null". So
|
|
||||||
// the event names the origin it was judged by, and the content script
|
|
||||||
// passes the address on only to a page whose own origin is that one
|
|
||||||
// (src/content/index.js).
|
|
||||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||||
const hasPermission =
|
const hasPermission =
|
||||||
activeAddress &&
|
activeAddress &&
|
||||||
(allowed.includes(origin) ||
|
(allowed.includes(origin) ||
|
||||||
(await isConnected(origin, activeAddress)));
|
connectedSites[origin + ":" + activeAddress]);
|
||||||
// Same as chainChanged above: a tab without our content script
|
// Same as chainChanged above: a tab without our content script
|
||||||
// rejects, and that is expected rather than a fault.
|
// rejects, and that is expected rather than a fault.
|
||||||
tabsSendMessage(tab.id, {
|
tabsSendMessage(tab.id, {
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
eventName: "accountsChanged",
|
eventName: "accountsChanged",
|
||||||
data: hasPermission ? [activeAddress] : [],
|
data: hasPermission ? [activeAddress] : [],
|
||||||
origin,
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1378,17 +1245,6 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
|
|||||||
// balances that came back — by address, onto whatever storage holds NOW.
|
// balances that came back — by address, onto whatever storage holds NOW.
|
||||||
// Applying by address rather than writing the array back is what keeps a
|
// Applying by address rather than writing the array back is what keeps a
|
||||||
// wallet or address added, renamed or deleted during the round trip.
|
// wallet or address added, renamed or deleted during the round trip.
|
||||||
//
|
|
||||||
// Nothing is applied when the network stored NOW is not the one the refresh
|
|
||||||
// ran on. A network switch during the round trip cleared every address's
|
|
||||||
// balances (applyChainSwitchFields()), and the previous network's must not come
|
|
||||||
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
|
|
||||||
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
|
|
||||||
// the new network.
|
|
||||||
//
|
|
||||||
// Blockscout may have answered just before the user turned "Use Blockscout"
|
|
||||||
// off. So the save reads the stored setting again, and drops every token the
|
|
||||||
// user has not tracked if the setting is off.
|
|
||||||
async function backgroundRefresh() {
|
async function backgroundRefresh() {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -1412,7 +1268,6 @@ async function backgroundRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateState((fresh) => {
|
await updateState((fresh) => {
|
||||||
if (fresh.networkId !== s.networkId) return;
|
|
||||||
for (const wallet of fresh.wallets) {
|
for (const wallet of fresh.wallets) {
|
||||||
for (const addr of wallet.addresses || []) {
|
for (const addr of wallet.addresses || []) {
|
||||||
const got = refreshed.get(String(addr.address).toLowerCase());
|
const got = refreshed.get(String(addr.address).toLowerCase());
|
||||||
@@ -1426,9 +1281,6 @@ async function backgroundRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!fresh.useBlockscout) {
|
|
||||||
dropUntrackedTokens(fresh.wallets, fresh.trackedTokens);
|
|
||||||
}
|
|
||||||
fresh.lastBalanceRefresh = now;
|
fresh.lastBalanceRefresh = now;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1484,44 +1336,35 @@ if (runtime.onStartup) {
|
|||||||
}
|
}
|
||||||
startBackgroundJobs();
|
startBackgroundJobs();
|
||||||
|
|
||||||
// The toolbar popup keeps its URL when the worker stops. A worker that starts
|
// When approval window is closed without a response, treat as rejection.
|
||||||
// holds no approvals, so the toolbar popup is pointed back at the wallet.
|
// "Without a response" is the operative part: the popup stays open across the
|
||||||
updateToolbarPopup();
|
// verify and broadcast it is waiting on, so a user closing an apparently-hung
|
||||||
|
// window is an ordinary event with an attempt already in flight behind it.
|
||||||
// A transaction or signature approval stays pending when its window closes.
|
// settleApproval() refuses those, which leaves the attempt to report its real
|
||||||
// The browser can close that window when the user clicks away from it, for
|
// outcome to the page — and the window is recorded as gone, so that an attempt
|
||||||
// example to copy the password from a password manager. The approval is marked
|
// which then fails retryably settles instead of waiting in a window that no
|
||||||
// as closed, so the toolbar popup can open it (see updateToolbarPopup()). An
|
// longer exists.
|
||||||
// attempt already running when the window closes goes on and reports its
|
|
||||||
// outcome to the page.
|
|
||||||
//
|
//
|
||||||
// This listener rejects a site-connection or network-switch approval whose
|
// A site-connection approval whose popup connected its port is not decided
|
||||||
// window closes before its popup connected its port. Once the port is
|
// here. That popup approves and closes in the same breath, and this event
|
||||||
// connected, the approval is left to the port. That popup approves and closes
|
// races the decision on a channel of its own — the same race the port exists
|
||||||
// in the same breath, and this event races the decision on a channel of its
|
// to end. Its port disconnect says the same thing this event does, in an order
|
||||||
// own — the same race the port exists to end. Its port disconnect says the
|
// that is defined, so the disconnect is left to say it. The window closing
|
||||||
// same thing this event does, in an order that is defined, so the disconnect is
|
// before any port connected is the one case with nothing else to speak for it,
|
||||||
// left to say it. The window closing before any port connected is the one case
|
// and is rejected here so the dApp is not left waiting on a window that is
|
||||||
// with nothing else to speak for it, and is rejected here so the dApp is not
|
// gone.
|
||||||
// left waiting on a window that is gone.
|
|
||||||
if (windowsNs && windowsNs.onRemoved) {
|
if (windowsNs && windowsNs.onRemoved) {
|
||||||
windowsNs.onRemoved.addListener((windowId) => {
|
windowsNs.onRemoved.addListener((windowId) => {
|
||||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||||
if (approval.windowId !== windowId) continue;
|
if (approval.windowId !== windowId) continue;
|
||||||
if (approval.type === "tx" || approval.type === "sign") {
|
const isSite = approval.type !== "tx" && approval.type !== "sign";
|
||||||
approval.windowClosed = true;
|
if (isSite && approval.portConnected) continue;
|
||||||
updateToolbarPopup();
|
const rejection = abandonedResult(
|
||||||
continue;
|
approval,
|
||||||
}
|
APPROVAL_REJECTED_CODE,
|
||||||
if (approval.portConnected) continue;
|
APPROVAL_REJECTED_MESSAGE,
|
||||||
settleApproval(
|
|
||||||
id,
|
|
||||||
abandonedResult(
|
|
||||||
approval,
|
|
||||||
APPROVAL_REJECTED_CODE,
|
|
||||||
APPROVAL_REJECTED_MESSAGE,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (!settleApproval(id, rejection)) approval.windowClosed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1533,14 +1376,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// the message carries. Firefox before 126 gives no sender.origin, so
|
// the message carries. Firefox before 126 gives no sender.origin, so
|
||||||
// the origin of sender.url is used: the frame that sent the message,
|
// the origin of sender.url is used: the frame that sent the message,
|
||||||
// not the tab's page, which may be another site embedding that
|
// not the tab's page, which may be another site embedding that
|
||||||
// frame.
|
// frame. With neither, the request is refused.
|
||||||
//
|
|
||||||
// A site is scheme://host[:port], so a request is refused when its
|
|
||||||
// origin has no host, as when it has no origin at all, rather than
|
|
||||||
// credited to the site its URL names. The browser reports "null" for
|
|
||||||
// a page with an opaque origin, such as one its server sandboxes with
|
|
||||||
// a Content-Security-Policy, and Chrome reports "file://" for a local
|
|
||||||
// file.
|
|
||||||
let trustedOrigin = sender.origin;
|
let trustedOrigin = sender.origin;
|
||||||
if (!trustedOrigin && sender.url) {
|
if (!trustedOrigin && sender.url) {
|
||||||
try {
|
try {
|
||||||
@@ -1549,13 +1385,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// an unparseable URL leaves the origin unknown
|
// an unparseable URL leaves the origin unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let host = "";
|
if (!trustedOrigin) {
|
||||||
try {
|
|
||||||
host = new URL(trustedOrigin).host;
|
|
||||||
} catch {
|
|
||||||
// no origin, or "null", is not a URL and so has no host
|
|
||||||
}
|
|
||||||
if (!host) {
|
|
||||||
sendResponse({
|
sendResponse({
|
||||||
error: {
|
error: {
|
||||||
code: 4100,
|
code: 4100,
|
||||||
@@ -1593,7 +1423,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
"AUTISTMASK_ADDRESSES_REMOVED",
|
||||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
"AUTISTMASK_GET_CONNECTED_SITES",
|
||||||
"AUTISTMASK_REMOVE_SITE",
|
"AUTISTMASK_REMOVE_SITE",
|
||||||
"AUTISTMASK_NETWORK_CHANGED",
|
|
||||||
];
|
];
|
||||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||||
sendResponse({ error: "Unauthorized sender" });
|
sendResponse({ error: "Unauthorized sender" });
|
||||||
@@ -1617,11 +1446,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
resp.signParams = approval.signParams;
|
resp.signParams = approval.signParams;
|
||||||
resp.approvedFrom = approval.approvedFrom;
|
resp.approvedFrom = approval.approvedFrom;
|
||||||
}
|
}
|
||||||
if (approval.type === "network") {
|
|
||||||
resp.type = "network";
|
|
||||||
resp.currentNetworkId = approval.currentNetworkId;
|
|
||||||
resp.requestedNetworkId = approval.requestedNetworkId;
|
|
||||||
}
|
|
||||||
// Flag if the requesting domain is on the phishing blocklist.
|
// Flag if the requesting domain is on the phishing blocklist.
|
||||||
resp.isPhishingDomain = isPhishingDomain(
|
resp.isPhishingDomain = isPhishingDomain(
|
||||||
extractHostname(approval.origin),
|
extractHostname(approval.origin),
|
||||||
@@ -1666,9 +1490,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// The popup waits for this answer before it closes its window.
|
return true;
|
||||||
sendResponse(null);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Keep the
|
// The popup signs; it reports back here when it could not. Keep the
|
||||||
@@ -1754,7 +1576,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (outcome.spendApproval) {
|
if (outcome.spendApproval) {
|
||||||
settleApproval(
|
settleApproval(
|
||||||
msg.id,
|
msg.id,
|
||||||
{ error: requestError(outcome.error) },
|
{ error: { message: outcome.error } },
|
||||||
{ holdsClaim: true },
|
{ holdsClaim: true },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -1781,7 +1603,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
|
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
|
||||||
settleApproval(
|
settleApproval(
|
||||||
msg.id,
|
msg.id,
|
||||||
{ error: requestError(outcome.error) },
|
{ error: { message: outcome.error } },
|
||||||
{ holdsClaim: true },
|
{ holdsClaim: true },
|
||||||
);
|
);
|
||||||
sendResponse({
|
sendResponse({
|
||||||
@@ -1814,13 +1636,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// that says plainly it did not take the transaction:
|
// that says plainly it did not take the transaction:
|
||||||
// describeTxFailure() reclassifies that, and the stage it
|
// describeTxFailure() reclassifies that, and the stage it
|
||||||
// returns is the one reported.
|
// returns is the one reported.
|
||||||
//
|
|
||||||
// A node that refused the transaction gives the page its own
|
|
||||||
// code and data, whatever sentence the page is shown.
|
|
||||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
||||||
settleApproval(
|
settleApproval(
|
||||||
msg.id,
|
msg.id,
|
||||||
{ error: requestError(outcome.error, nodeError(e)) },
|
{ error: { message: outcome.error } },
|
||||||
{ holdsClaim: true },
|
{ holdsClaim: true },
|
||||||
);
|
);
|
||||||
sendResponse({
|
sendResponse({
|
||||||
@@ -1868,8 +1687,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
sendResponse(null);
|
return true;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Keep the
|
// The popup signs; it reports back here when it could not. Keep the
|
||||||
@@ -1892,8 +1710,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const s = await getState();
|
const activeAddress = await getActiveAddress();
|
||||||
const activeAddress = activeAddressOf(s);
|
|
||||||
// Same as the transaction path: the address the approval named
|
// Same as the transaction path: the address the approval named
|
||||||
// is the one that must have signed, and a switch since then is
|
// is the one that must have signed, and a switch since then is
|
||||||
// a refusal rather than a signature from another account.
|
// a refusal rather than a signature from another account.
|
||||||
@@ -1912,29 +1729,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
signature,
|
signature,
|
||||||
approval.approvedFrom,
|
approval.approvedFrom,
|
||||||
);
|
);
|
||||||
// The sign screen refuses typed data whose message ethers
|
|
||||||
// would sign differently from how it is shown, or whose
|
|
||||||
// domain names another chain. A signature for it that arrives
|
|
||||||
// anyway is refused here, against the network active now.
|
|
||||||
const sp = approval.signParams;
|
|
||||||
if (
|
|
||||||
sp.method === "eth_signTypedData_v4" ||
|
|
||||||
sp.method === "eth_signTypedData"
|
|
||||||
) {
|
|
||||||
const data = JSON.parse(sp.typedData);
|
|
||||||
const refusal =
|
|
||||||
typedDataMessageRefusal(
|
|
||||||
data.types,
|
|
||||||
signedPrimaryType(data.types),
|
|
||||||
data.message,
|
|
||||||
"",
|
|
||||||
) ||
|
|
||||||
typedDataChainRefusal(
|
|
||||||
data.domain,
|
|
||||||
networkById(s.networkId),
|
|
||||||
);
|
|
||||||
if (refusal) throw new ApprovalMismatchError(refusal);
|
|
||||||
}
|
|
||||||
settleApproval(msg.id, { signature }, { holdsClaim: true });
|
settleApproval(msg.id, { signature }, { holdsClaim: true });
|
||||||
sendResponse({ signature });
|
sendResponse({ signature });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1943,7 +1737,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (!retryable) {
|
if (!retryable) {
|
||||||
settleApproval(
|
settleApproval(
|
||||||
msg.id,
|
msg.id,
|
||||||
{ error: requestError(errMsg) },
|
{ error: { message: errMsg } },
|
||||||
{ holdsClaim: true },
|
{ holdsClaim: true },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -1971,63 +1765,40 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The popup removed these addresses, so no site stays connected to them.
|
// The popup removed these addresses, so no site stays connected to them.
|
||||||
|
// A connectedSites key is origin + ":" + address, and an origin can carry
|
||||||
|
// a port, so the address is what follows the last colon.
|
||||||
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
|
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
|
||||||
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
|
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
|
||||||
endConnections((key) => {
|
for (const key of Object.keys(connectedSites)) {
|
||||||
const address = key.slice(key.lastIndexOf(":") + 1);
|
const address = key.slice(key.lastIndexOf(":") + 1);
|
||||||
return removed.some((a) => sameAddress(a, address));
|
if (removed.some((a) => sameAddress(a, address))) {
|
||||||
}).catch((err) => {
|
delete connectedSites[key];
|
||||||
log.errorf(
|
}
|
||||||
"ending the removed addresses' connections failed:",
|
}
|
||||||
err,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings lists the sites connected without "Remember", which only
|
// Settings lists the sites connected without "Remember", which only this
|
||||||
// session storage holds.
|
// worker knows. The origin is what precedes the key's last colon.
|
||||||
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
||||||
sessionStorageGet(null)
|
sendResponse(
|
||||||
.then((items) =>
|
Object.keys(connectedSites).map((key) =>
|
||||||
Object.keys(items).map((key) =>
|
key.slice(0, key.lastIndexOf(":")),
|
||||||
key.slice(0, key.lastIndexOf(":")),
|
),
|
||||||
),
|
);
|
||||||
)
|
return false;
|
||||||
.catch((err) => {
|
|
||||||
log.errorf("listing the connected sites failed:", err);
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
.then(sendResponse);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings removed this site (msg.origin) and has already dropped its
|
// Settings removed this site (msg.origin) and has already dropped its
|
||||||
// remembered entries. Its connections approved without "Remember" end
|
// remembered entries. Its connections approved without "Remember" end
|
||||||
// here, under every address, and its open tabs are told it has no account.
|
// here, under every address, and its open tabs are told it has no account.
|
||||||
// The answer goes back once they have ended, and Settings waits for it
|
|
||||||
// before it lists the connected sites again.
|
|
||||||
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
||||||
endConnections(
|
for (const key of Object.keys(connectedSites)) {
|
||||||
(key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
|
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
|
||||||
)
|
delete connectedSites[key];
|
||||||
.catch((err) => {
|
}
|
||||||
log.errorf(
|
}
|
||||||
"ending the removed site's connections failed:",
|
broadcastSiteRemoved(msg.origin);
|
||||||
err,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
broadcastSiteRemoved(msg.origin);
|
|
||||||
sendResponse(null);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings switched the network and has saved it. Open tabs are told the
|
|
||||||
// new chain id the same way as after a site's approved switch request.
|
|
||||||
if (msg.type === "AUTISTMASK_NETWORK_CHANGED") {
|
|
||||||
broadcastChainChanged(msg.chainId);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-15
@@ -16,10 +16,8 @@
|
|||||||
// across any number of awaits and no concurrent work can
|
// across any number of awaits and no concurrent work can
|
||||||
// move it.
|
// move it.
|
||||||
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
||||||
// back — all holding the lock every write of the stored
|
// back — all inside a queue, so two background writes
|
||||||
// record takes, so no other write lands in between, by
|
// never interleave, and the read is one storage round trip
|
||||||
// the background or by any page (README, "Stored state
|
|
||||||
// and its version"). The read is one storage round trip
|
|
||||||
// ahead of the write rather than a page lifetime ahead of
|
// ahead of the write rather than a page lifetime ahead of
|
||||||
// it (which is what made the popup's saveState() need a
|
// it (which is what made the popup's saveState() need a
|
||||||
// per-field merge against a baseline at all).
|
// per-field merge against a baseline at all).
|
||||||
@@ -28,7 +26,7 @@
|
|||||||
// against a snapshot it owns, and applies the RESULT inside updateState().
|
// against a snapshot it owns, and applies the RESULT inside updateState().
|
||||||
// It never publishes an object other in-flight work is holding.
|
// It never publishes an object other in-flight work is holding.
|
||||||
|
|
||||||
const { storageGet, storageSet, withLock } = require("../shared/browserApi");
|
const { storageGet, storageSet } = require("../shared/browserApi");
|
||||||
const { normalizePersisted } = require("../shared/persistedState");
|
const { normalizePersisted } = require("../shared/persistedState");
|
||||||
const {
|
const {
|
||||||
STATE_SCHEMA_VERSION,
|
STATE_SCHEMA_VERSION,
|
||||||
@@ -53,6 +51,11 @@ async function getState() {
|
|||||||
return normalizePersisted(result.autistmask);
|
return normalizePersisted(result.autistmask);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serializes the read-modify-write turns below. Two of them interleaved would
|
||||||
|
// each read before the other wrote, and the second write would carry the first
|
||||||
|
// one's fields back to their pre-turn values.
|
||||||
|
let updateQueue = Promise.resolve();
|
||||||
|
|
||||||
async function updateStateOnce(mutate) {
|
async function updateStateOnce(mutate) {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
await mutate(s);
|
await mutate(s);
|
||||||
@@ -67,21 +70,27 @@ async function updateStateOnce(mutate) {
|
|||||||
|
|
||||||
// Apply `mutate` to a record read fresh from storage and write the result
|
// Apply `mutate` to a record read fresh from storage and write the result
|
||||||
// back. `mutate` receives a detached, normalized profile and mutates it in
|
// back. `mutate` receives a detached, normalized profile and mutates it in
|
||||||
// place; it may be async, but it must not do anything slow. The write is the
|
// place; it may be async, but it must not do anything slow — the window
|
||||||
// WHOLE record, which is safe only because the lock (withLock() in
|
// between the read and the write is the window in which another context's
|
||||||
// src/shared/browserApi.js) is held from the read to the write: two
|
// write is lost, and keeping it to one storage round trip is what makes a
|
||||||
// background turns, or a turn and a page's save, never interleave
|
// whole-record write safe here. Concretely: the write is the WHOLE record, so
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
|
// a popup write that lands inside that window is reverted, in every field, by
|
||||||
// while it is held (README, "Stored state and its version").
|
// the record this turn read before it. That is accepted because the window is
|
||||||
|
// one round trip long and the popup is not writing while the worker is;
|
||||||
|
// widening it is what would make it a real hazard.
|
||||||
//
|
//
|
||||||
// `mutate` must also not call updateState() itself, directly or through
|
// `mutate` must also not call updateState() itself, directly or through
|
||||||
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
// anything it awaits: the queue is strictly serial, so the inner turn waits on
|
||||||
// and the outer one is waiting on the inner one. That deadlocks every write of
|
// the outer one, which is waiting on the inner one. That deadlocks the whole
|
||||||
// the stored record, not just the caller. Mutate the record you were handed.
|
// background, not just the caller. Mutate the record you were handed.
|
||||||
//
|
//
|
||||||
// Resolves with the record that was written.
|
// Resolves with the record that was written.
|
||||||
function updateState(mutate) {
|
function updateState(mutate) {
|
||||||
return withLock("autistmask", () => updateStateOnce(mutate));
|
const turn = updateQueue.then(() => updateStateOnce(mutate));
|
||||||
|
// The queue must advance even when a turn rejects, or every update after
|
||||||
|
// it queues behind a promise that never settles.
|
||||||
|
updateQueue = turn.catch(() => {});
|
||||||
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getState, updateState };
|
module.exports = { getState, updateState };
|
||||||
|
|||||||
+9
-18
@@ -49,23 +49,14 @@ window.addEventListener("message", (event) => {
|
|||||||
|
|
||||||
// Listen for events pushed from the background (e.g. accountsChanged)
|
// Listen for events pushed from the background (e.g. accountsChanged)
|
||||||
runtimeApi().onMessage.addListener((msg) => {
|
runtimeApi().onMessage.addListener((msg) => {
|
||||||
if (msg.type !== "AUTISTMASK_EVENT") return;
|
if (msg.type === "AUTISTMASK_EVENT") {
|
||||||
// The background chooses the addresses in accountsChanged for the site
|
window.postMessage(
|
||||||
// at the origin the event names, which is the origin of the tab's URL.
|
{
|
||||||
// This page hears them only when its own origin is that one. A page whose
|
type: "AUTISTMASK_EVENT",
|
||||||
// origin has no host is not a site: one its server sandboxes with a
|
eventName: msg.eventName,
|
||||||
// Content-Security-Policy has the origin "null" whatever its URL, and is
|
data: msg.data,
|
||||||
// told no address, as its requests are refused.
|
},
|
||||||
let data = msg.data;
|
"*",
|
||||||
if (msg.eventName === "accountsChanged" && msg.origin !== window.origin) {
|
);
|
||||||
data = [];
|
|
||||||
}
|
}
|
||||||
window.postMessage(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: msg.eventName,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,10 +99,7 @@
|
|||||||
|
|
||||||
const provider = {
|
const provider = {
|
||||||
isAutistMask: true,
|
isAutistMask: true,
|
||||||
// An interface-compatibility flag many dApps check. Kept as a written
|
isMetaMask: true, // compatibility — many dApps check this
|
||||||
// exception to the rule that no competitor is named in code: see
|
|
||||||
// Policies in README.md, and RULES.md.
|
|
||||||
isMetaMask: true,
|
|
||||||
chainId: currentChainId,
|
chainId: currentChainId,
|
||||||
networkVersion: currentNetworkVersion,
|
networkVersion: currentNetworkVersion,
|
||||||
selectedAddress: null,
|
selectedAddress: null,
|
||||||
|
|||||||
+89
-314
@@ -7,10 +7,9 @@
|
|||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
||||||
font and its size on body, and a Tailwind class beats it only when
|
font on body, and a Tailwind class beats it only when marked
|
||||||
marked important: hence font-mono! and text-sm! rather than
|
important: hence font-mono! rather than font-mono. -->
|
||||||
font-mono and text-sm. -->
|
<body class="bg-bg text-fg font-mono! text-sm">
|
||||||
<body class="bg-bg text-fg font-mono! text-sm!">
|
|
||||||
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
||||||
<!-- ============ GLOBAL TITLE BAR ============ -->
|
<!-- ============ GLOBAL TITLE BAR ============ -->
|
||||||
<div
|
<div
|
||||||
@@ -43,38 +42,6 @@
|
|||||||
<!-- ============ WELCOME / FIRST USE ============ -->
|
<!-- ============ WELCOME / FIRST USE ============ -->
|
||||||
<div id="view-welcome" class="view hidden">
|
<div id="view-welcome" class="view hidden">
|
||||||
<p class="mb-3">Welcome! To get started, add a wallet.</p>
|
<p class="mb-3">Welcome! To get started, add a wallet.</p>
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
|
||||||
<p class="text-xs mb-2">
|
|
||||||
This wallet sends your data only to the Ethereum RPC
|
|
||||||
endpoint, unless you turn on one of these. You can
|
|
||||||
change them later in Settings.
|
|
||||||
</p>
|
|
||||||
<label
|
|
||||||
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
|
|
||||||
>
|
|
||||||
<input type="checkbox" id="welcome-use-blockscout" />
|
|
||||||
Use Blockscout
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted mb-2">
|
|
||||||
Shows transaction history, transaction details and the
|
|
||||||
tokens you have not tracked. Sends your addresses, and
|
|
||||||
the hash of each transaction you open, to the Blockscout
|
|
||||||
API.
|
|
||||||
</p>
|
|
||||||
<label
|
|
||||||
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="welcome-check-recipients-on-etherscan"
|
|
||||||
/>
|
|
||||||
Check recipients on Etherscan
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted">
|
|
||||||
Sends each recipient's address to Etherscan to check it
|
|
||||||
for a phishing or scam label.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
id="btn-welcome-add"
|
id="btn-welcome-add"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
@@ -120,14 +87,11 @@
|
|||||||
|
|
||||||
<!-- Mnemonic form section -->
|
<!-- Mnemonic form section -->
|
||||||
<div id="add-wallet-section-mnemonic">
|
<div id="add-wallet-section-mnemonic">
|
||||||
<!-- The die button shares the instruction's row: on a row
|
<p class="mb-2">
|
||||||
of its own it pushes Import below the fold at
|
Enter your 12 or 24 word recovery phrase below, or click
|
||||||
396x600. -->
|
the button to roll the die for a new one.
|
||||||
<div class="mb-2 flex justify-between items-start gap-2">
|
</p>
|
||||||
<p>
|
<div class="mb-1 flex justify-end">
|
||||||
Enter your 12 or 24 word recovery phrase below, or
|
|
||||||
click the button to roll the die for a new one.
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
id="btn-generate-phrase"
|
id="btn-generate-phrase"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
|
||||||
@@ -243,22 +207,12 @@
|
|||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- The error line sits beside Import, not above it: at
|
<button
|
||||||
396x600 the button already starts near the bottom of
|
id="btn-add-wallet-confirm"
|
||||||
the popup, and a line of its own would push it below
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
the fold. The longest error fits on one line here. -->
|
>
|
||||||
<div class="flex items-center gap-2">
|
Import
|
||||||
<button
|
</button>
|
||||||
id="btn-add-wallet-confirm"
|
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
id="add-wallet-password-error"
|
|
||||||
class="text-xs min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
|
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
|
||||||
@@ -304,10 +258,6 @@
|
|||||||
|
|
||||||
<!-- wallet list -->
|
<!-- wallet list -->
|
||||||
<div id="wallet-list"></div>
|
<div id="wallet-list"></div>
|
||||||
<p id="home-tokens-note" class="text-xs text-muted py-1 hidden">
|
|
||||||
Only tracked tokens are listed, because "Use Blockscout" is
|
|
||||||
off in Settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- recent transactions across all addresses -->
|
<!-- recent transactions across all addresses -->
|
||||||
<div>
|
<div>
|
||||||
@@ -372,13 +322,6 @@
|
|||||||
<div id="address-balances" class="min-h-[1.25rem]">
|
<div id="address-balances" class="min-h-[1.25rem]">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
id="address-tokens-note"
|
|
||||||
class="text-xs text-muted hidden"
|
|
||||||
>
|
|
||||||
Only tracked tokens are listed, because "Use Blockscout"
|
|
||||||
is off in Settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
@@ -453,6 +396,10 @@
|
|||||||
Warning: anyone with this private key can access and
|
Warning: anyone with this private key can access and
|
||||||
transfer all funds from this address. Never share it.
|
transfer all funds from this address. Never share it.
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
id="export-privkey-flash"
|
||||||
|
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
<div id="export-privkey-password-section" class="mb-2">
|
<div id="export-privkey-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -461,13 +408,9 @@
|
|||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
placeholder="Enter your password to continue"
|
placeholder="Enter your password to continue"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
id="export-privkey-password-error"
|
|
||||||
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
id="btn-export-privkey-confirm"
|
id="btn-export-privkey-confirm"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
|
||||||
>
|
>
|
||||||
Reveal
|
Reveal
|
||||||
</button>
|
</button>
|
||||||
@@ -569,23 +512,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1">What to send</label>
|
<label class="block mb-1">What to send</label>
|
||||||
<!-- The token dropdown and the line under it. Both are
|
<select
|
||||||
hidden when AddressToken opens Send for its own token. -->
|
id="send-token"
|
||||||
<div id="send-token-dropdown">
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
<select
|
>
|
||||||
id="send-token"
|
<option value="ETH">ETH</option>
|
||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
</select>
|
||||||
>
|
|
||||||
<option value="ETH">ETH</option>
|
|
||||||
</select>
|
|
||||||
<p
|
|
||||||
id="send-tokens-note"
|
|
||||||
class="text-xs text-muted hidden"
|
|
||||||
>
|
|
||||||
Only tracked tokens are listed, because "Use
|
|
||||||
Blockscout" is off in Settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div id="send-token-static" class="text-sm hidden"></div>
|
<div id="send-token-static" class="text-sm hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -688,12 +620,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-warnings" class="mb-2 invisible"></div>
|
<div id="confirm-warnings" class="mb-2 invisible"></div>
|
||||||
<!-- These two sentences name the network's native token, so
|
<div id="confirm-recipient-warning" class="mb-2 invisible">
|
||||||
show() in confirmTx.js sets them. -->
|
<div
|
||||||
<div
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
id="confirm-recipient-warning"
|
>
|
||||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
|
WARNING: The recipient address has ZERO transaction
|
||||||
></div>
|
history. This may indicate a fresh or unused address.
|
||||||
|
Double-check the address before sending.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Its sentence names the network's native token, so show()
|
||||||
|
in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-contract-warning"
|
id="confirm-contract-warning"
|
||||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
|
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
|
||||||
@@ -714,31 +651,10 @@
|
|||||||
phishing/scam. Do not send funds to this address.
|
phishing/scam. Do not send funds to this address.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
id="confirm-etherscan-note"
|
|
||||||
class="mb-2 text-xs text-muted hidden"
|
|
||||||
>
|
|
||||||
This address was not checked on Etherscan, because "Check
|
|
||||||
recipients on Etherscan" is off in Settings.
|
|
||||||
</p>
|
|
||||||
<div
|
<div
|
||||||
id="confirm-errors"
|
id="confirm-errors"
|
||||||
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
|
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
|
||||||
></div>
|
></div>
|
||||||
<!-- Its sentence names the token, or why the token's balance
|
|
||||||
is unknown, so show() in confirmTx.js sets it. -->
|
|
||||||
<div
|
|
||||||
id="confirm-balance-error"
|
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
id="confirm-balance-read-error"
|
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
|
||||||
>
|
|
||||||
Your balance could not be read from the network, so this
|
|
||||||
transaction cannot be checked against it. Please go back and
|
|
||||||
try again.
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
id="confirm-amount-fee-error"
|
id="confirm-amount-fee-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||||
@@ -758,33 +674,6 @@
|
|||||||
id="confirm-fee-unknown-error"
|
id="confirm-fee-unknown-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||||
></div>
|
></div>
|
||||||
<!-- Shown when Sign & Send refuses a send for its network, for
|
|
||||||
a From address the selected wallet does not hold, or for its
|
|
||||||
gas limit or fee. Each holds its space from the first paint, so
|
|
||||||
the refusal moves nothing. -->
|
|
||||||
<div
|
|
||||||
id="confirm-network-changed-error"
|
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
|
||||||
>
|
|
||||||
The active network changed after this transaction was
|
|
||||||
reviewed, so it was not sent. Please go back and review it
|
|
||||||
again.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="confirm-from-address-error"
|
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
|
||||||
>
|
|
||||||
The selected wallet does not hold the From address above, so
|
|
||||||
nothing was sent. Please go back and send again.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="confirm-fee-bound-error"
|
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
|
||||||
>
|
|
||||||
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.
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -811,7 +700,6 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="wait-tx-summary" class="font-bold"></div>
|
<div id="wait-tx-summary" class="font-bold"></div>
|
||||||
<div id="wait-tx-token-note"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
@@ -858,7 +746,6 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="error-tx-summary" class="font-bold"></div>
|
<div id="error-tx-summary" class="font-bold"></div>
|
||||||
<div id="error-tx-token-note"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
@@ -942,7 +829,7 @@
|
|||||||
id="add-token-info"
|
id="add-token-info"
|
||||||
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
||||||
></div>
|
></div>
|
||||||
<div id="add-token-common" class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
>Or pick a common token:</label
|
>Or pick a common token:</label
|
||||||
>
|
>
|
||||||
@@ -980,23 +867,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
|
||||||
<h3 class="font-bold mb-1">Passwords</h3>
|
|
||||||
<label
|
|
||||||
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="settings-allow-weak-passwords"
|
|
||||||
/>
|
|
||||||
Allow weak passwords
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted">
|
|
||||||
Lets you choose a password shorter than 12 characters
|
|
||||||
when you add a wallet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Tracked Tokens</h3>
|
<h3 class="font-bold mb-1">Tracked Tokens</h3>
|
||||||
<p class="text-xs text-muted mb-2">
|
<p class="text-xs text-muted mb-2">
|
||||||
@@ -1045,9 +915,9 @@
|
|||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Network</h3>
|
<h3 class="font-bold mb-1">Network</h3>
|
||||||
<p class="text-xs text-muted mb-1">
|
<p class="text-xs text-muted mb-1">
|
||||||
Select the Ethereum network. Switching restores the RPC
|
Select the Ethereum network. Switching networks will
|
||||||
and Blockscout endpoints last used on that network, or
|
update the RPC and Blockscout endpoints to their
|
||||||
that network's defaults if it has none.
|
defaults.
|
||||||
</p>
|
</p>
|
||||||
<div class="text-xs flex items-center gap-1">
|
<div class="text-xs flex items-center gap-1">
|
||||||
<select
|
<select
|
||||||
@@ -1082,21 +952,9 @@
|
|||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Blockscout API</h3>
|
<h3 class="font-bold mb-1">Blockscout API</h3>
|
||||||
<label
|
|
||||||
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
|
|
||||||
>
|
|
||||||
<input type="checkbox" id="settings-use-blockscout" />
|
|
||||||
Use Blockscout
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted mb-2">
|
|
||||||
Shows transaction history, transaction details and the
|
|
||||||
tokens you have not tracked. Sends your addresses, and
|
|
||||||
the hash of each transaction you open, to the Blockscout
|
|
||||||
API below.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted mb-1">
|
<p class="text-xs text-muted mb-1">
|
||||||
Change the URL below if you run your own Blockscout
|
Used to fetch token balances and transaction history.
|
||||||
instance.
|
Change this if you run your own Blockscout instance.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1111,23 +969,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
|
||||||
<h3 class="font-bold mb-1">Etherscan</h3>
|
|
||||||
<label
|
|
||||||
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="settings-check-recipients-on-etherscan"
|
|
||||||
/>
|
|
||||||
Check recipients on Etherscan
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted">
|
|
||||||
Sends each recipient's address to Etherscan to check it
|
|
||||||
for a phishing or scam label.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Token Spam Protection</h3>
|
<h3 class="font-bold mb-1">Token Spam Protection</h3>
|
||||||
<p class="text-xs text-muted mb-2">
|
<p class="text-xs text-muted mb-2">
|
||||||
@@ -1273,9 +1114,12 @@
|
|||||||
<p class="text-xs mb-3">
|
<p class="text-xs mb-3">
|
||||||
Deleting
|
Deleting
|
||||||
<strong id="delete-wallet-name"></strong> is permanent. Any
|
<strong id="delete-wallet-name"></strong> is permanent. Any
|
||||||
funds will be unrecoverable without your
|
funds will be unrecoverable without your recovery phrase.
|
||||||
<span id="delete-wallet-key-material"></span>.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
id="delete-wallet-flash"
|
||||||
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -1285,10 +1129,6 @@
|
|||||||
placeholder="Enter your password to confirm"
|
placeholder="Enter your password to confirm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="delete-wallet-password-error"
|
|
||||||
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
id="btn-delete-wallet-confirm"
|
id="btn-delete-wallet-confirm"
|
||||||
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
@@ -1325,13 +1165,13 @@
|
|||||||
money at its addresses is not moved or destroyed.
|
money at its addresses is not moved or destroyed.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs mb-2">
|
<p class="text-xs mb-2">
|
||||||
If you have kept a copy of this wallet's
|
If you have the recovery phrase for this wallet written
|
||||||
<span id="delete-wallet-lost-key-material"></span>, add the
|
down, add the wallet again afterwards with a new password
|
||||||
wallet again afterwards with a new password and you will
|
and you will have it back.
|
||||||
have it back.
|
|
||||||
<strong
|
<strong
|
||||||
>If you have not, deleting this wallet means losing
|
>If you do not have it written down, deleting this
|
||||||
everything it holds, forever.</strong
|
wallet means losing everything it holds,
|
||||||
|
forever.</strong
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs mb-3">Your other wallets are not touched.</p>
|
<p class="text-xs mb-3">Your other wallets are not touched.</p>
|
||||||
@@ -1400,13 +1240,6 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
id="delete-address-tokens-note"
|
|
||||||
class="text-xs text-muted mb-2 hidden"
|
|
||||||
>
|
|
||||||
Only tracked tokens are listed, because "Use Blockscout" is
|
|
||||||
off in Settings.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted mb-3">
|
<p class="text-xs text-muted mb-3">
|
||||||
A wallet always keeps at least one address. To remove the
|
A wallet always keeps at least one address. To remove the
|
||||||
last one, delete the whole wallet from Settings instead.
|
last one, delete the whole wallet from Settings instead.
|
||||||
@@ -1440,6 +1273,10 @@
|
|||||||
this wallet, from any device, without your password. Never
|
this wallet, from any device, without your password. Never
|
||||||
type them into a website and never show them to anyone.
|
type them into a website and never show them to anyone.
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="show-phrase-flash"
|
||||||
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
<div id="show-phrase-password-section" class="mb-2">
|
<div id="show-phrase-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -1448,13 +1285,9 @@
|
|||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
placeholder="Enter your password to continue"
|
placeholder="Enter your password to continue"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
id="show-phrase-password-error"
|
|
||||||
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
id="btn-show-phrase-reveal"
|
id="btn-show-phrase-reveal"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
|
||||||
>
|
>
|
||||||
Reveal
|
Reveal
|
||||||
</button>
|
</button>
|
||||||
@@ -1479,39 +1312,36 @@
|
|||||||
<h2 class="font-bold mb-2">Add Token</h2>
|
<h2 class="font-bold mb-2">Add Token</h2>
|
||||||
<p class="text-xs text-muted mb-3">
|
<p class="text-xs text-muted mb-3">
|
||||||
Pick a common token or enter a contract address manually.
|
Pick a common token or enter a contract address manually.
|
||||||
Common tokens are offered on Ethereum Mainnet only.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="settings-addtoken-common">
|
<!-- top 10 quick-pick buttons -->
|
||||||
<!-- top 10 quick-pick buttons -->
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="block mb-1 text-xs text-muted"
|
||||||
<label class="block mb-1 text-xs text-muted"
|
>Top tokens:</label
|
||||||
>Top tokens:</label
|
>
|
||||||
>
|
<div
|
||||||
<div
|
id="settings-addtoken-top10"
|
||||||
id="settings-addtoken-top10"
|
class="flex flex-wrap gap-1"
|
||||||
class="flex flex-wrap gap-1"
|
></div>
|
||||||
></div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- top 100 dropdown -->
|
<!-- top 100 dropdown -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
>Or pick from top 100:</label
|
>Or pick from top 100:</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="settings-addtoken-select"
|
id="settings-addtoken-select"
|
||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
>
|
>
|
||||||
<option value="">-- select --</option>
|
<option value="">-- select --</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
id="btn-settings-addtoken-select"
|
id="btn-settings-addtoken-select"
|
||||||
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
>
|
>
|
||||||
Add selected
|
Add selected
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- manual contract address -->
|
<!-- manual contract address -->
|
||||||
@@ -1620,16 +1450,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shown in place of the block, decoded details, network
|
|
||||||
details and raw data, which only Blockscout gives. -->
|
|
||||||
<p
|
|
||||||
id="tx-detail-blockscout-note"
|
|
||||||
class="text-xs text-muted mx-1 mb-3 hidden"
|
|
||||||
>
|
|
||||||
This screen shows no block, decoded details, network details
|
|
||||||
or raw data, because "Use Blockscout" is off in Settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- ── Decoded details ── -->
|
<!-- ── Decoded details ── -->
|
||||||
<div id="tx-detail-calldata-section" class="hidden">
|
<div id="tx-detail-calldata-section" class="hidden">
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
@@ -1882,7 +1702,10 @@
|
|||||||
<div class="text-xs text-muted mb-1">
|
<div class="text-xs text-muted mb-1">
|
||||||
Address that will be shared:
|
Address that will be shared:
|
||||||
</div>
|
</div>
|
||||||
<div id="approve-address" class="text-xs mb-2"></div>
|
<div
|
||||||
|
id="approve-address"
|
||||||
|
class="text-xs flex items-center mb-2"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label
|
<label
|
||||||
@@ -1908,54 +1731,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ NETWORK SWITCH APPROVAL ============ -->
|
|
||||||
<div id="view-approve-network" class="view hidden">
|
|
||||||
<h2 class="font-bold mb-2">Network Switch Request</h2>
|
|
||||||
<div
|
|
||||||
id="approve-network-phishing-warning"
|
|
||||||
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
|
|
||||||
>
|
|
||||||
⚠️ PHISHING WARNING: This site is on a known phishing
|
|
||||||
blocklist. Proceed with extreme caution.
|
|
||||||
</div>
|
|
||||||
<p class="mb-2">
|
|
||||||
<span id="approve-network-origin" class="font-bold"></span>
|
|
||||||
wants to switch the wallet's network.
|
|
||||||
</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="text-xs text-muted mb-1">Current network</div>
|
|
||||||
<div
|
|
||||||
id="approve-network-current"
|
|
||||||
class="text-xs font-bold"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="text-xs text-muted mb-1">Requested network</div>
|
|
||||||
<div
|
|
||||||
id="approve-network-requested"
|
|
||||||
class="text-xs font-bold"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p class="mb-3 text-xs">
|
|
||||||
Switching changes the network for the whole wallet and for
|
|
||||||
every site, not only for this one.
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<button
|
|
||||||
id="btn-approve-network"
|
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
|
||||||
>
|
|
||||||
Switch
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="btn-reject-network"
|
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ STATE RECOVERY ============ -->
|
<!-- ============ STATE RECOVERY ============ -->
|
||||||
<!--
|
<!--
|
||||||
Shown when the stored profile cannot be read at all. Every
|
Shown when the stored profile cannot be read at all. Every
|
||||||
|
|||||||
+15
-189
@@ -5,15 +5,12 @@ const {
|
|||||||
state,
|
state,
|
||||||
saveState,
|
saveState,
|
||||||
onSaveFailure,
|
onSaveFailure,
|
||||||
takeStoredNetwork,
|
|
||||||
clearBalanceNotFetched,
|
|
||||||
loadState,
|
loadState,
|
||||||
} = require("../shared/state");
|
} = require("../shared/state");
|
||||||
const { StateUnusableError } = require("../shared/stateSchema");
|
const { StateUnusableError } = require("../shared/stateSchema");
|
||||||
const { log, setRuntimeDebug } = require("../shared/log");
|
const { log, setRuntimeDebug } = require("../shared/log");
|
||||||
const { refreshPrices } = require("../shared/prices");
|
const { refreshPrices } = require("../shared/prices");
|
||||||
const { refreshBalances, dropUntrackedTokens } = require("../shared/balances");
|
const { refreshBalances } = require("../shared/balances");
|
||||||
const { clearPopupCaches } = require("../shared/chainSwitch");
|
|
||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
@@ -24,13 +21,6 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
} = require("./views/helpers");
|
} = require("./views/helpers");
|
||||||
const { applyTheme } = require("./theme");
|
const { applyTheme } = require("./theme");
|
||||||
const {
|
|
||||||
onStorageChanged,
|
|
||||||
tabsGetCurrent,
|
|
||||||
tabsGetZoom,
|
|
||||||
windowsGetCurrent,
|
|
||||||
windowsUpdate,
|
|
||||||
} = require("../shared/browserApi");
|
|
||||||
// Renders a view the popup lands on without having navigated to it forward:
|
// Renders a view the popup lands on without having navigated to it forward:
|
||||||
// on restore here, and on Back. Only the views that can be fully re-rendered
|
// on restore here, and on Back. Only the views that can be fully re-rendered
|
||||||
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
|
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
|
||||||
@@ -68,84 +58,28 @@ window.addEventListener("pagehide", () => pageClosed.abort());
|
|||||||
|
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
|
||||||
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
// The ten-second refresh init() starts, stopped when the popup moves to the
|
||||||
// the recovery screen: there is no profile left to refresh.
|
// recovery screen: there is no profile left to refresh.
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
|
||||||
// The refresh first takes the stored network, "Use Blockscout" and "Check
|
|
||||||
// recipients on Etherscan" (followStoredSettings() below). So the screens it
|
|
||||||
// draws reflect any change saved elsewhere.
|
|
||||||
//
|
|
||||||
// The refresh writes into a copy of the wallets. Inside the save, under the
|
|
||||||
// lock, its results are copied onto the address records in `state` only if the
|
|
||||||
// network it ran on is still the active one, on this page and in the stored
|
|
||||||
// record; the background's switch, after the user approves a site's request,
|
|
||||||
// reaches this page only after the stored record (followStoredSettings()).
|
|
||||||
// A switch while the refresh ran cleared every address's balances
|
|
||||||
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
|
|
||||||
// previous network's must not come back over them
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
|
|
||||||
// recorded as done. Results are copied by address, so an address added or
|
|
||||||
// removed meanwhile is left as it is. Blockscout may have answered before the
|
|
||||||
// user turned "Use Blockscout" off. So the save drops every token the user has
|
|
||||||
// not tracked if the stored setting is off by then.
|
|
||||||
//
|
|
||||||
// The save also deletes `balanceNotFetched` from each tracked token whose
|
|
||||||
// balance this refresh read at every address. trackToken() in
|
|
||||||
// src/popup/views/helpers.js sets that field. The save takes the field off the
|
|
||||||
// matching tokens of the stored record (clearBalanceNotFetched() in
|
|
||||||
// src/shared/state.js). The rest of the stored tracked token list stays as it
|
|
||||||
// is.
|
|
||||||
//
|
|
||||||
// Once the save is done, the wallet list is drawn again. So are the balances
|
|
||||||
// on AddressDetail or AddressToken when that screen is up. No other screen is
|
|
||||||
// drawn again, DeleteAddress and its balance lines included.
|
|
||||||
async function doRefreshAndRender() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) return;
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
await followStoredSettings();
|
await Promise.all([
|
||||||
const networkId = state.networkId;
|
|
||||||
const wallets = structuredClone(state.wallets);
|
|
||||||
const [, readTokens] = await Promise.all([
|
|
||||||
refreshPrices(),
|
refreshPrices(),
|
||||||
refreshBalances(
|
refreshBalances(
|
||||||
wallets,
|
state.wallets,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
networkId,
|
state.networkId,
|
||||||
pageClosed.signal,
|
pageClosed.signal,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const refreshed = new Map();
|
state.lastBalanceRefresh = Date.now();
|
||||||
for (const wallet of wallets) {
|
await saveState();
|
||||||
for (const addr of wallet.addresses) {
|
|
||||||
refreshed.set(addr.address, addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await saveState((stored) => {
|
|
||||||
if (state.networkId !== networkId) return false;
|
|
||||||
if (stored.networkId !== networkId) return false;
|
|
||||||
for (const wallet of state.wallets) {
|
|
||||||
for (const addr of wallet.addresses) {
|
|
||||||
const got = refreshed.get(addr.address);
|
|
||||||
if (!got) continue;
|
|
||||||
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
|
||||||
if (got[key] !== undefined) addr[key] = got[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!stored.useBlockscout) {
|
|
||||||
dropUntrackedTokens(state.wallets, stored.trackedTokens);
|
|
||||||
}
|
|
||||||
clearBalanceNotFetched(stored, readTokens);
|
|
||||||
state.lastBalanceRefresh = Date.now();
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
if (state.currentView === "address") addressDetail.renderBalances();
|
|
||||||
if (state.currentView === "address-token") addressToken.renderBalance();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Every call site fires this and walks away — the boot below, the ten
|
// Every call site fires this and walks away — the boot below, the ten
|
||||||
// second interval, and eight views through ctx — so it must never
|
// second interval, and eight views through ctx — so it must never
|
||||||
@@ -159,24 +93,9 @@ async function 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.
|
|
||||||
function startRefresh() {
|
|
||||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
|
||||||
doRefreshAndRender();
|
|
||||||
if (refreshTimer === null) {
|
|
||||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
renderWalletList,
|
renderWalletList,
|
||||||
doRefreshAndRender,
|
doRefreshAndRender,
|
||||||
startRefresh,
|
|
||||||
restoreView,
|
|
||||||
pageClosed: pageClosed.signal,
|
pageClosed: pageClosed.signal,
|
||||||
showAddWalletView: () => {
|
showAddWalletView: () => {
|
||||||
pushCurrentView();
|
pushCurrentView();
|
||||||
@@ -244,85 +163,10 @@ function fallbackView() {
|
|||||||
showView("main");
|
showView("main");
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the background saves a network switch, once the user approves a site's
|
|
||||||
// request, or another popup page saves one, this page follows the stored
|
|
||||||
// record. It takes the network the record names as a save reads it, holding
|
|
||||||
// the lock, so no later save of this page writes the previous network back, and
|
|
||||||
// then leaves the page as a switch in Settings leaves it. This page's own
|
|
||||||
// switch in Settings is made under the lock as well (onChainSwitch() in
|
|
||||||
// src/shared/chainSwitch.js), so a record naming a network other than this
|
|
||||||
// page's always holds a switch saved elsewhere, and a switch the user picks
|
|
||||||
// here while this waits for the lock is made after it.
|
|
||||||
//
|
|
||||||
// Another page may change "Use Blockscout" or "Check recipients on Etherscan".
|
|
||||||
// This page takes either change the same way and draws its screens again. This
|
|
||||||
// page's own changes to the two settings are made under the lock too
|
|
||||||
// (views/settings.js, views/welcome.js).
|
|
||||||
async function followStoredSettings() {
|
|
||||||
let changed = false;
|
|
||||||
// A failed save is reported by saveState() itself: see onSaveFailure().
|
|
||||||
await saveState((stored) => {
|
|
||||||
if (stored.networkId !== state.networkId) {
|
|
||||||
settings.cutViewStack();
|
|
||||||
takeStoredNetwork(stored);
|
|
||||||
clearPopupCaches();
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (stored.useBlockscout !== state.useBlockscout) {
|
|
||||||
settings.setUseBlockscout(stored.useBlockscout);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stored.checkRecipientsOnEtherscan !==
|
|
||||||
state.checkRecipientsOnEtherscan
|
|
||||||
) {
|
|
||||||
state.checkRecipientsOnEtherscan =
|
|
||||||
stored.checkRecipientsOnEtherscan;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (!changed) return false;
|
|
||||||
}).catch(() => {});
|
|
||||||
if (changed) settings.showNewSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The background opens the approval window as wide as the page, but the
|
|
||||||
// browser counts the window's frame in that width, and a screen taller than
|
|
||||||
// the window takes a vertical scrollbar out of it too. So once the screen is
|
|
||||||
// up, the window is widened by however far the page would scroll sideways.
|
|
||||||
// That distance is in page pixels and the window's width in screen pixels:
|
|
||||||
// at 150% zoom one page pixel is 1.5 screen pixels. The zoom is read for this
|
|
||||||
// page's own tab, because asked without a tab id Firefox answers for the
|
|
||||||
// window focused last. Firefox draws some zoom steps a little larger than the
|
|
||||||
// zoom it reports, 170% among them, so once the page has its new width (its
|
|
||||||
// resize event) it is measured again, and the window widened again while the
|
|
||||||
// page still falls short, three times at most. A window that cannot grow sends
|
|
||||||
// no resize event, and the page stops there. An approval can also show in the
|
|
||||||
// toolbar popup, whose window is the user's browser window; that one is left
|
|
||||||
// alone.
|
|
||||||
async function widenApprovalWindow() {
|
|
||||||
const page = document.documentElement;
|
|
||||||
if (page.scrollWidth <= page.clientWidth) return;
|
|
||||||
const win = await windowsGetCurrent();
|
|
||||||
if (win.type !== "popup") return;
|
|
||||||
const tab = await tabsGetCurrent();
|
|
||||||
const zoom = await tabsGetZoom(tab.id);
|
|
||||||
let width = win.width;
|
|
||||||
for (let tries = 0; tries < 3; tries++) {
|
|
||||||
const sideways = page.scrollWidth - page.clientWidth;
|
|
||||||
if (sideways <= 0) return;
|
|
||||||
width += Math.ceil(sideways * zoom);
|
|
||||||
const resized = new Promise((resolve) =>
|
|
||||||
window.addEventListener("resize", resolve, { once: true }),
|
|
||||||
);
|
|
||||||
await windowsUpdate(win.id, { width });
|
|
||||||
await resized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// First, before anything can save: showView() saves on every navigation
|
// First, before anything can save: showView() saves on every navigation
|
||||||
// without awaiting, so a save that fails from here on has somewhere to be
|
// without awaiting, so a save that fails from here on has somewhere to be
|
||||||
// reported rather than reaching only the console
|
// reported rather than being swallowed by the save queue
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||||
// the approval-window branch below too, since that window saves as well.
|
// the approval-window branch below too, since that window saves as well.
|
||||||
//
|
//
|
||||||
@@ -394,9 +238,9 @@ async function init() {
|
|||||||
// rejection rather than an uncaught error — measured as still failing
|
// rejection rather than an uncaught error — measured as still failing
|
||||||
// the run on both harnesses (Playwright `pageerror`, and the Firefox
|
// the run on both harnesses (Playwright `pageerror`, and the Firefox
|
||||||
// driver's console-service drain), so nothing is lost by leaving it
|
// driver's console-service drain), so nothing is lost by leaving it
|
||||||
// on that path. show() puts the approval's own screen up once the
|
// on that path.
|
||||||
// background has described it; the window is widened to fit it then.
|
approval.show(approvalId);
|
||||||
approval.show(approvalId).then(widenApprovalWindow);
|
showView("approve-site");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,31 +273,13 @@ async function init() {
|
|||||||
settingsAddToken.init(ctx);
|
settingsAddToken.init(ctx);
|
||||||
deleteAddress.init(ctx);
|
deleteAddress.init(ctx);
|
||||||
|
|
||||||
// This page follows a record only when its network, "Use Blockscout" or
|
|
||||||
// "Check recipients on Etherscan" differs from this page's. A record
|
|
||||||
// written by this page's own save matches this page. A record naming no
|
|
||||||
// network is no switch.
|
|
||||||
onStorageChanged((changes) => {
|
|
||||||
const written = changes.autistmask && changes.autistmask.newValue;
|
|
||||||
if (!written || !written.networkId) return;
|
|
||||||
if (
|
|
||||||
written.networkId !== state.networkId ||
|
|
||||||
written.useBlockscout !== state.useBlockscout ||
|
|
||||||
written.checkRecipientsOnEtherscan !==
|
|
||||||
state.checkRecipientsOnEtherscan
|
|
||||||
) {
|
|
||||||
followStoredSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// A change saved since loadState() above, before there was a listener.
|
|
||||||
followStoredSettings();
|
|
||||||
|
|
||||||
if (!state.hasWallet) {
|
if (!state.hasWallet) {
|
||||||
welcome.show();
|
showView("welcome");
|
||||||
} else {
|
} else {
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
restoreView();
|
restoreView();
|
||||||
startRefresh();
|
doRefreshAndRender();
|
||||||
|
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-35
@@ -13,7 +13,6 @@
|
|||||||
// cannot be required outside a browser.
|
// cannot be required outside a browser.
|
||||||
|
|
||||||
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
||||||
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
|
|
||||||
|
|
||||||
// The views this page load has rendered.
|
// The views this page load has rendered.
|
||||||
//
|
//
|
||||||
@@ -38,16 +37,9 @@ function markViewRendered(view) {
|
|||||||
if (view) renderedViews.add(view);
|
if (view) renderedViews.add(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin a fresh page-load scope. The popup gets one by being loaded. It also
|
// Begin a fresh page-load scope. The popup gets one by being loaded; the
|
||||||
// asks for one when every screen on the page is out of date:
|
// unit tests, which simulate several page loads against one module
|
||||||
//
|
// instance, ask for one.
|
||||||
// - after a network switch, since those screens show the previous network
|
|
||||||
// (showNewSettings() in views/settings.js)
|
|
||||||
// - after "Use Blockscout" or "Check recipients on Etherscan" changes, here
|
|
||||||
// or on another page, since those screens were drawn under the old setting
|
|
||||||
//
|
|
||||||
// The unit tests ask for one too. They simulate several page loads against one
|
|
||||||
// module instance.
|
|
||||||
function resetRenderedViews() {
|
function resetRenderedViews() {
|
||||||
renderedViews.clear();
|
renderedViews.clear();
|
||||||
}
|
}
|
||||||
@@ -132,33 +124,25 @@ function isRenderableDecoded(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
// The pending transaction confirmTx.show() renders: `token` reaches
|
||||||
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
||||||
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
// addressTitle(), makeBlockie() and getLocalWarnings().
|
||||||
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
|
|
||||||
// when it is absent, picks the network whose explorer the address links open,
|
|
||||||
// so it has to name one.
|
|
||||||
function isRenderablePendingTx(value) {
|
function isRenderablePendingTx(value) {
|
||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
isText(value.token) &&
|
isText(value.token) &&
|
||||||
isAddressText(value.from) &&
|
isAddressText(value.from) &&
|
||||||
isAddressText(value.to) &&
|
isAddressText(value.to)
|
||||||
isText(value.amount) &&
|
|
||||||
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The stored transaction transactionDetail.render() shows. contractAddress is
|
// The stored transaction transactionDetail.render() shows. contractAddress is
|
||||||
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
||||||
// `chainId` picks the network whose explorer the links open and whose
|
|
||||||
// Blockscout the transaction is read from, so it has to name one.
|
|
||||||
function isRenderableTx(value) {
|
function isRenderableTx(value) {
|
||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
isAddressText(value.from) &&
|
isAddressText(value.from) &&
|
||||||
isAddressText(value.to) &&
|
isAddressText(value.to) &&
|
||||||
(!value.contractAddress || isAddressText(value.contractAddress)) &&
|
(!value.contractAddress || isAddressText(value.contractAddress))
|
||||||
SUPPORTED_CHAIN_IDS.has(value.chainId)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,19 +194,11 @@ function renderView(view, state, views) {
|
|||||||
if (!data.hash) return false;
|
if (!data.hash) return false;
|
||||||
if (!isAddressText(data.to)) return false;
|
if (!isAddressText(data.to)) return false;
|
||||||
if (!isRenderableDecoded(data.decoded)) return false;
|
if (!isRenderableDecoded(data.decoded)) return false;
|
||||||
// `chainId` picks the network whose explorer the links open, so
|
|
||||||
// when it is there it has to name one. Likewise on error-tx.
|
|
||||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
views.txStatus.renderSuccess();
|
views.txStatus.renderSuccess();
|
||||||
return true;
|
return true;
|
||||||
case "error-tx":
|
case "error-tx":
|
||||||
if (!data.message) return false;
|
if (!data.message) return false;
|
||||||
if (!isAddressText(data.to)) return false;
|
if (!isAddressText(data.to)) return false;
|
||||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
views.txStatus.renderError();
|
views.txStatus.renderError();
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
@@ -237,10 +213,9 @@ function renderView(view, state, views) {
|
|||||||
// in the two cases where the view is known to be on the page already:
|
// in the two cases where the view is known to be on the page already:
|
||||||
//
|
//
|
||||||
// - It is not one the popup renders from persisted state. The restored
|
// - It is not one the popup renders from persisted state. The restored
|
||||||
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
|
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
|
||||||
// it the same way (views/settings.js), so such a view can only be on the
|
// be on the stack from this page load, where forward navigation
|
||||||
// stack from this page load, where forward navigation rendered it on the
|
// rendered it on the way in.
|
||||||
// way in.
|
|
||||||
// - This page load has rendered it. Re-rendering would re-fetch and
|
// - This page load has rendered it. Re-rendering would re-fetch and
|
||||||
// clobber what it holds; Home is rendered anyway, see above.
|
// clobber what it holds; Home is rendered anyway, see above.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
const {
|
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||||
$,
|
|
||||||
showView,
|
|
||||||
showFlash,
|
|
||||||
escapeHtml,
|
|
||||||
goBack,
|
|
||||||
trackToken,
|
|
||||||
} = require("./helpers");
|
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -16,12 +9,6 @@ function show() {
|
|||||||
$("add-token-address").value = "";
|
$("add-token-address").value = "";
|
||||||
$("add-token-info").textContent = "";
|
$("add-token-info").textContent = "";
|
||||||
$("add-token-info").style.visibility = "hidden";
|
$("add-token-info").style.visibility = "hidden";
|
||||||
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
|
||||||
// on any other network.
|
|
||||||
$("add-token-common").classList.toggle(
|
|
||||||
"hidden",
|
|
||||||
state.networkId !== "mainnet",
|
|
||||||
);
|
|
||||||
const list = $("common-token-list");
|
const list = $("common-token-list");
|
||||||
list.innerHTML = getTopTokens(25)
|
list.innerHTML = getTopTokens(25)
|
||||||
.map(
|
.map(
|
||||||
@@ -59,9 +46,6 @@ function init(ctx) {
|
|||||||
infoEl.textContent = "Looking up token...";
|
infoEl.textContent = "Looking up token...";
|
||||||
infoEl.style.visibility = "visible";
|
infoEl.style.visibility = "visible";
|
||||||
log.debugf("Looking up token contract", contractAddr);
|
log.debugf("Looking up token contract", contractAddr);
|
||||||
// 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;
|
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
contractAddr,
|
contractAddr,
|
||||||
@@ -69,17 +53,8 @@ function init(ctx) {
|
|||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
// A contract address names a token on one network only, so an
|
|
||||||
// answer after a switch is not added to the new network's list.
|
|
||||||
if (currentNetwork().chainId !== chainId) {
|
|
||||||
showFlash(
|
|
||||||
"The active network changed before the lookup finished," +
|
|
||||||
" so the token was not added.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.infof("Adding token", info.symbol, contractAddr);
|
log.infof("Adding token", info.symbol, contractAddr);
|
||||||
trackToken({
|
state.trackedTokens.push({
|
||||||
address: contractAddr,
|
address: contractAddr,
|
||||||
symbol: info.symbol,
|
symbol: info.symbol,
|
||||||
decimals: info.decimals,
|
decimals: info.decimals,
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
showError,
|
|
||||||
hideError,
|
|
||||||
goBack,
|
goBack,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -20,7 +18,6 @@ const {
|
|||||||
const { encryptWithPassword } = require("../../shared/vault");
|
const { encryptWithPassword } = require("../../shared/vault");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { scanForAddresses } = require("../../shared/balances");
|
const { scanForAddresses } = require("../../shared/balances");
|
||||||
const { notify } = require("../../shared/browserApi");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an address already exists in ANY wallet (hd, xprv, or key).
|
* Check if an address already exists in ANY wallet (hd, xprv, or key).
|
||||||
@@ -101,7 +98,6 @@ function clear() {
|
|||||||
$("add-wallet-password").value = "";
|
$("add-wallet-password").value = "";
|
||||||
$("add-wallet-password-confirm").value = "";
|
$("add-wallet-password-confirm").value = "";
|
||||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||||
hideError("add-wallet-password-error");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each wallet has its own password (its own encryptedSecret), so adding a
|
// Each wallet has its own password (its own encryptedSecret), so adding a
|
||||||
@@ -129,19 +125,15 @@ function validatePassword() {
|
|||||||
const pw = $("add-wallet-password").value;
|
const pw = $("add-wallet-password").value;
|
||||||
const pw2 = $("add-wallet-password-confirm").value;
|
const pw2 = $("add-wallet-password-confirm").value;
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
showError("add-wallet-password-error", "Please choose a password.");
|
showFlash("Please choose a password.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// "Allow weak passwords" in Settings lifts the 12-character minimum.
|
if (pw.length < 12) {
|
||||||
if (!state.allowWeakPasswords && pw.length < 12) {
|
showFlash("Password must be at least 12 characters.");
|
||||||
showError(
|
|
||||||
"add-wallet-password-error",
|
|
||||||
"Password must be at least 12 characters.",
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
showError("add-wallet-password-error", "Passwords do not match.");
|
showFlash("Passwords do not match.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return pw;
|
return pw;
|
||||||
@@ -186,19 +178,11 @@ async function importMnemonic(ctx) {
|
|||||||
encryptedSecret: encrypted,
|
encryptedSecret: encrypted,
|
||||||
nextIndex: 1,
|
nextIndex: 1,
|
||||||
addresses: [
|
addresses: [
|
||||||
{
|
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||||
address: firstAddress,
|
|
||||||
index: 0,
|
|
||||||
balance: "0.0000",
|
|
||||||
tokenBalances: [],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
state.wallets.push(wallet);
|
state.wallets.push(wallet);
|
||||||
state.hasWallet = true;
|
state.hasWallet = true;
|
||||||
if (state.activeAddress === null) {
|
|
||||||
state.activeAddress = firstAddress;
|
|
||||||
}
|
|
||||||
await saveState();
|
await saveState();
|
||||||
clearViewStack();
|
clearViewStack();
|
||||||
ctx.renderWalletList();
|
ctx.renderWalletList();
|
||||||
@@ -215,27 +199,18 @@ async function importMnemonic(ctx) {
|
|||||||
if (scan.addresses.length > 1) {
|
if (scan.addresses.length > 1) {
|
||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
index: a.index,
|
|
||||||
balance: "0.0000",
|
balance: "0.0000",
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
}));
|
}));
|
||||||
wallet.nextIndex = scan.nextIndex;
|
wallet.nextIndex = scan.nextIndex;
|
||||||
// The new list leaves out the first address when the scan found it
|
|
||||||
// unused. If it was the active one, the new list's first takes over,
|
|
||||||
// and the background is told as when the user picks one on Home.
|
|
||||||
const activeMoved =
|
|
||||||
state.activeAddress === firstAddress &&
|
|
||||||
wallet.addresses[0].address !== firstAddress;
|
|
||||||
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
|
|
||||||
await saveState();
|
await saveState();
|
||||||
ctx.renderWalletList();
|
ctx.renderWalletList();
|
||||||
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
||||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||||
} else {
|
} else {
|
||||||
showFlash("Ready.", 1000);
|
showFlash("Ready.", 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.startRefresh();
|
ctx.doRefreshAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importPrivateKey(ctx) {
|
async function importPrivateKey(ctx) {
|
||||||
@@ -267,15 +242,12 @@ async function importPrivateKey(ctx) {
|
|||||||
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
|
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
|
||||||
});
|
});
|
||||||
state.hasWallet = true;
|
state.hasWallet = true;
|
||||||
if (state.activeAddress === null) {
|
|
||||||
state.activeAddress = addr;
|
|
||||||
}
|
|
||||||
await saveState();
|
await saveState();
|
||||||
clearViewStack();
|
clearViewStack();
|
||||||
ctx.renderWalletList();
|
ctx.renderWalletList();
|
||||||
showView("main");
|
showView("main");
|
||||||
|
|
||||||
ctx.startRefresh();
|
ctx.doRefreshAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importXprvKey(ctx) {
|
async function importXprvKey(ctx) {
|
||||||
@@ -321,19 +293,11 @@ async function importXprvKey(ctx) {
|
|||||||
encryptedSecret: encrypted,
|
encryptedSecret: encrypted,
|
||||||
nextIndex: 1,
|
nextIndex: 1,
|
||||||
addresses: [
|
addresses: [
|
||||||
{
|
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||||
address: firstAddress,
|
|
||||||
index: 0,
|
|
||||||
balance: "0.0000",
|
|
||||||
tokenBalances: [],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
state.wallets.push(wallet);
|
state.wallets.push(wallet);
|
||||||
state.hasWallet = true;
|
state.hasWallet = true;
|
||||||
if (state.activeAddress === null) {
|
|
||||||
state.activeAddress = firstAddress;
|
|
||||||
}
|
|
||||||
await saveState();
|
await saveState();
|
||||||
clearViewStack();
|
clearViewStack();
|
||||||
ctx.renderWalletList();
|
ctx.renderWalletList();
|
||||||
@@ -350,26 +314,18 @@ async function importXprvKey(ctx) {
|
|||||||
if (scan.addresses.length > 1) {
|
if (scan.addresses.length > 1) {
|
||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
index: a.index,
|
|
||||||
balance: "0.0000",
|
balance: "0.0000",
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
}));
|
}));
|
||||||
wallet.nextIndex = scan.nextIndex;
|
wallet.nextIndex = scan.nextIndex;
|
||||||
// As for a recovery phrase: an active first address the scan left
|
|
||||||
// out gives way to the new list's first.
|
|
||||||
const activeMoved =
|
|
||||||
state.activeAddress === firstAddress &&
|
|
||||||
wallet.addresses[0].address !== firstAddress;
|
|
||||||
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
|
|
||||||
await saveState();
|
await saveState();
|
||||||
ctx.renderWalletList();
|
ctx.renderWalletList();
|
||||||
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
||||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||||
} else {
|
} else {
|
||||||
showFlash("Ready.", 1000);
|
showFlash("Ready.", 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.startRefresh();
|
ctx.doRefreshAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
@@ -386,10 +342,8 @@ function init(ctx) {
|
|||||||
$("add-wallet-phrase-warning").style.visibility = "visible";
|
$("add-wallet-phrase-warning").style.visibility = "visible";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import / confirm. Each press starts with no password error on screen:
|
// Import / confirm
|
||||||
// validatePassword() puts it back if the password is still wrong.
|
|
||||||
$("btn-add-wallet-confirm").addEventListener("click", async () => {
|
$("btn-add-wallet-confirm").addEventListener("click", async () => {
|
||||||
hideError("add-wallet-password-error");
|
|
||||||
if (currentMode === "mnemonic") {
|
if (currentMode === "mnemonic") {
|
||||||
await importMnemonic(ctx);
|
await importMnemonic(ctx);
|
||||||
} else if (currentMode === "privkey") {
|
} else if (currentMode === "privkey") {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
txCounterpartyHtml,
|
txCounterpartyHtml,
|
||||||
HISTORY_OFF_TEXT,
|
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -66,27 +65,11 @@ function show() {
|
|||||||
});
|
});
|
||||||
$("address-line").dataset.full = addr.address;
|
$("address-line").dataset.full = addr.address;
|
||||||
attachCopyHandlers($("address-line"));
|
attachCopyHandlers($("address-line"));
|
||||||
|
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||||
|
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
||||||
const ensEl = $("address-ens");
|
const ensEl = $("address-ens");
|
||||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||||
ensEl.classList.add("hidden");
|
ensEl.classList.add("hidden");
|
||||||
renderBalances();
|
|
||||||
// Only Blockscout reports the tokens the user has not tracked.
|
|
||||||
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
|
||||||
renderSendTokenSelect(addr);
|
|
||||||
$("tx-list").innerHTML =
|
|
||||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
|
||||||
showView("address");
|
|
||||||
loadTransactions(addr.address);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The USD total and the balance list. Drawn again by each balance refresh
|
|
||||||
// that returns while this screen is up (doRefreshAndRender() in
|
|
||||||
// src/popup/index.js), which leaves the rest of the screen as it is.
|
|
||||||
function renderBalances() {
|
|
||||||
const addr =
|
|
||||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
|
||||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
|
||||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
|
||||||
$("address-balances").innerHTML = balanceLinesForAddress(
|
$("address-balances").innerHTML = balanceLinesForAddress(
|
||||||
addr,
|
addr,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
@@ -100,6 +83,11 @@ function renderBalances() {
|
|||||||
ctx.showAddressToken();
|
ctx.showAddressToken();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
renderSendTokenSelect(addr);
|
||||||
|
$("tx-list").innerHTML =
|
||||||
|
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||||
|
showView("address");
|
||||||
|
loadTransactions(addr.address);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadedTxs = [];
|
let loadedTxs = [];
|
||||||
@@ -167,10 +155,6 @@ async function loadTransactions(address) {
|
|||||||
|
|
||||||
function renderTransactions(txs) {
|
function renderTransactions(txs) {
|
||||||
const list = $("tx-list");
|
const list = $("tx-list");
|
||||||
if (!state.useBlockscout) {
|
|
||||||
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (txs.length === 0) {
|
if (txs.length === 0) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||||
@@ -245,7 +229,7 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
$("send-to").value = "";
|
$("send-to").value = "";
|
||||||
$("send-amount").value = "";
|
$("send-amount").value = "";
|
||||||
$("send-token-dropdown").classList.remove("hidden");
|
$("send-token").classList.remove("hidden");
|
||||||
$("send-token-static").classList.add("hidden");
|
$("send-token-static").classList.add("hidden");
|
||||||
updateSendBalance();
|
updateSendBalance();
|
||||||
resetSendValidation();
|
resetSendValidation();
|
||||||
@@ -282,7 +266,7 @@ function init(_ctx) {
|
|||||||
// This address's private key can be derived from the stored key,
|
// This address's private key can be derived from the stored key,
|
||||||
// but export goes through getSignerForAddress, which refuses a key
|
// but export goes through getSignerForAddress, which refuses a key
|
||||||
// that is not a master key. Without this the export screen would
|
// that is not a master key. Without this the export screen would
|
||||||
// take a password and then refuse.
|
// take a password and then report that refusal as a wrong password.
|
||||||
const defect = selectedWalletDefect();
|
const defect = selectedWalletDefect();
|
||||||
if (defect) {
|
if (defect) {
|
||||||
showFlash(defect.shortMessage);
|
showFlash(defect.shortMessage);
|
||||||
@@ -296,4 +280,4 @@ function init(_ctx) {
|
|||||||
exportPrivkey.init();
|
exportPrivkey.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show, renderBalances };
|
module.exports = { init, show };
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
txCounterpartyHtml,
|
txCounterpartyHtml,
|
||||||
HISTORY_OFF_TEXT,
|
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -49,18 +48,37 @@ function show() {
|
|||||||
const addr = wallet.addresses[state.selectedAddress];
|
const addr = wallet.addresses[state.selectedAddress];
|
||||||
const ai = state.selectedAddress;
|
const ai = state.selectedAddress;
|
||||||
const tokenId = state.selectedToken;
|
const tokenId = state.selectedToken;
|
||||||
|
|
||||||
|
// Determine token symbol and balance
|
||||||
|
let symbol, amount, price;
|
||||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
||||||
currentSymbol =
|
if (tokenId === "ETH") {
|
||||||
tokenId === "ETH"
|
symbol = nativeCurrency();
|
||||||
? nativeCurrency()
|
amount = parseFloat(addr.balance || "0");
|
||||||
: resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens);
|
price = getPrice("ETH");
|
||||||
|
} else {
|
||||||
|
const tb = (addr.tokenBalances || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
|
||||||
|
);
|
||||||
|
symbol = resolveSymbol(
|
||||||
|
tokenId,
|
||||||
|
addr.tokenBalances,
|
||||||
|
state.trackedTokens,
|
||||||
|
);
|
||||||
|
// null when the scale is unknown: no quantity to show, and none to
|
||||||
|
// price. balanceLine() states that rather than printing 0.0000.
|
||||||
|
amount = tb ? unknownableAmount(tb.balance) : 0;
|
||||||
|
price = getPrice(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSymbol = symbol;
|
||||||
|
|
||||||
$("address-token-title").textContent =
|
$("address-token-title").textContent =
|
||||||
wallet.name +
|
wallet.name +
|
||||||
" \u2014 Address " +
|
" \u2014 Address " +
|
||||||
(ai + 1) +
|
(ai + 1) +
|
||||||
" \u2014 " +
|
" \u2014 " +
|
||||||
displaySymbol(currentSymbol);
|
displaySymbol(symbol);
|
||||||
|
|
||||||
// Blockie
|
// Blockie
|
||||||
const blockieEl = $("address-token-jazzicon");
|
const blockieEl = $("address-token-jazzicon");
|
||||||
@@ -82,7 +100,13 @@ function show() {
|
|||||||
$("address-token-line").dataset.full = addr.address;
|
$("address-token-line").dataset.full = addr.address;
|
||||||
attachCopyHandlers($("address-token-line"));
|
attachCopyHandlers($("address-token-line"));
|
||||||
|
|
||||||
renderBalance();
|
// USD total for this token only
|
||||||
|
const usdVal = price && amount !== null ? amount * price : null;
|
||||||
|
const usdStr = formatUsd(usdVal);
|
||||||
|
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||||
|
|
||||||
|
// Single token balance line (no tokenId — not clickable here)
|
||||||
|
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||||
|
|
||||||
// Token contract details (ERC-20 only)
|
// Token contract details (ERC-20 only)
|
||||||
const contractInfo = $("address-token-contract-info");
|
const contractInfo = $("address-token-contract-info");
|
||||||
@@ -144,47 +168,6 @@ function show() {
|
|||||||
loadTransactions(addr.address, tokenId);
|
loadTransactions(addr.address, tokenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The USD total and the balance line of the selected token. Drawn again by
|
|
||||||
// each balance refresh that returns while this screen is up
|
|
||||||
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
|
|
||||||
// screen as it is.
|
|
||||||
function renderBalance() {
|
|
||||||
const addr =
|
|
||||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
|
||||||
const tokenId = state.selectedToken;
|
|
||||||
let amount, price;
|
|
||||||
if (tokenId === "ETH") {
|
|
||||||
amount = parseFloat(addr.balance || "0");
|
|
||||||
price = getPrice("ETH");
|
|
||||||
} else {
|
|
||||||
const id = tokenId.toLowerCase();
|
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === id,
|
|
||||||
);
|
|
||||||
const tracked = state.trackedTokens.find(
|
|
||||||
(t) => t.address.toLowerCase() === id,
|
|
||||||
);
|
|
||||||
// null when the scale is unknown, or when no refresh has read the
|
|
||||||
// balance of a token just added: no quantity to show, and none to
|
|
||||||
// price. balanceLine() states that rather than printing 0.0000.
|
|
||||||
if (tb) amount = unknownableAmount(tb.balance);
|
|
||||||
else amount = tracked && tracked.balanceNotFetched ? null : 0;
|
|
||||||
price = getPrice(currentSymbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
// USD total for this token only
|
|
||||||
const usdVal = price && amount !== null ? amount * price : null;
|
|
||||||
const usdStr = formatUsd(usdVal);
|
|
||||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
|
||||||
|
|
||||||
// Single token balance line (no tokenId — not clickable here)
|
|
||||||
$("address-token-balance").innerHTML = balanceLine(
|
|
||||||
currentSymbol,
|
|
||||||
amount,
|
|
||||||
price,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTransactions(address, tokenId) {
|
async function loadTransactions(address, tokenId) {
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
@@ -256,10 +239,6 @@ async function loadTransactions(address, tokenId) {
|
|||||||
|
|
||||||
function renderTransactions(txs) {
|
function renderTransactions(txs) {
|
||||||
const list = $("address-token-tx-list");
|
const list = $("address-token-tx-list");
|
||||||
if (!state.useBlockscout) {
|
|
||||||
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (txs.length === 0) {
|
if (txs.length === 0) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||||
@@ -345,7 +324,7 @@ function init(_ctx) {
|
|||||||
$("send-token").value = tokenId;
|
$("send-token").value = tokenId;
|
||||||
}
|
}
|
||||||
// Hide dropdown, show static token display
|
// Hide dropdown, show static token display
|
||||||
$("send-token-dropdown").classList.add("hidden");
|
$("send-token").classList.add("hidden");
|
||||||
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
|
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
|
||||||
if (tokenId !== "ETH") {
|
if (tokenId !== "ETH") {
|
||||||
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
|
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
|
||||||
@@ -364,4 +343,4 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show, renderBalance };
|
module.exports = { init, show };
|
||||||
|
|||||||
+83
-226
@@ -11,12 +11,9 @@ const {
|
|||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
formatFee,
|
||||||
tokenLabel,
|
tokenLabel,
|
||||||
blockscoutOffTokenNoteHtml,
|
|
||||||
tokenSourcesByChainId,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const {
|
const {
|
||||||
networkById,
|
|
||||||
networkByChainId,
|
networkByChainId,
|
||||||
nativeCurrencyByChainId,
|
nativeCurrencyByChainId,
|
||||||
} = require("../../shared/networks");
|
} = require("../../shared/networks");
|
||||||
@@ -29,6 +26,7 @@ const {
|
|||||||
Interface,
|
Interface,
|
||||||
MaxUint256,
|
MaxUint256,
|
||||||
toUtf8String,
|
toUtf8String,
|
||||||
|
TypedDataEncoder,
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
@@ -47,27 +45,16 @@ const {
|
|||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { walletDefect } = require("../../shared/walletDefects");
|
const { walletDefect } = require("../../shared/walletDefects");
|
||||||
const {
|
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||||
describeSigningFailure,
|
|
||||||
signedPrimaryType,
|
|
||||||
typedDataMessageRefusal,
|
|
||||||
typedDataChainRefusal,
|
|
||||||
} = require("../../shared/approvalVerify");
|
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
const uniswap = require("../../shared/uniswap");
|
const uniswap = require("../../shared/uniswap");
|
||||||
const { runtimeApi, sendMessage } = require("../../shared/browserApi");
|
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||||
|
|
||||||
const erc20Iface = new Interface(ERC20_ABI);
|
const erc20Iface = new Interface(ERC20_ABI);
|
||||||
|
|
||||||
// The link opens the explorer of the network with `chainId`, or of the active
|
function approvalAddressHtml(address) {
|
||||||
// network when none is given. A chain id that names no network has no
|
|
||||||
// explorer, so the address is drawn without a link.
|
|
||||||
function approvalAddressHtml(address, chainId) {
|
|
||||||
const title = addressTitle(address, state.wallets);
|
const title = addressTitle(address, state.wallets);
|
||||||
if (chainId && !networkByChainId(chainId)) {
|
return renderAddressHtml(address, { title });
|
||||||
return renderAddressHtml(address, { title, noLink: true });
|
|
||||||
}
|
|
||||||
return renderAddressHtml(address, { title, chainId });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The amount line for a decoded ERC-20 call. With a known scale it is the
|
// The amount line for a decoded ERC-20 call. With a known scale it is the
|
||||||
@@ -87,18 +74,18 @@ function tokenAmountText(rawAmount, decimals, symbol) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
|
// Try to decode calldata using known ABIs.
|
||||||
// with chain id `chainId`, using known ABIs.
|
|
||||||
// Returns { name, description, details } or null.
|
// Returns { name, description, details } or null.
|
||||||
function decodeCalldata(data, toAddress, chainId, fromAddress) {
|
function decodeCalldata(data, toAddress) {
|
||||||
if (!data || data === "0x" || data.length < 10) return null;
|
if (!data || data === "0x" || data.length < 10) return null;
|
||||||
|
|
||||||
// Where a token's name and scale are looked for, for every decoder below:
|
// Where a token's scale is looked for, for every decoder below: the ERC-20
|
||||||
// what the wallet knows of the tokens on the network with `chainId`, which
|
// amount line and the swap's Amount and Min. received lines resolve it the
|
||||||
// need not be the active one. The ERC-20 amount line and the swap's Amount
|
// same way, and refuse to format the same way when it is nowhere.
|
||||||
// and Min. received lines resolve the scale the same way, and refuse to
|
const decimalsSources = {
|
||||||
// format the same way when it is nowhere.
|
trackedTokens: state.trackedTokens,
|
||||||
const decimalsSources = tokenSourcesByChainId(chainId);
|
wallets: state.wallets,
|
||||||
|
};
|
||||||
|
|
||||||
// Try ERC-20 (approve / transfer)
|
// Try ERC-20 (approve / transfer)
|
||||||
try {
|
try {
|
||||||
@@ -193,13 +180,7 @@ function decodeCalldata(data, toAddress, chainId, fromAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try Uniswap Universal Router
|
// Try Uniswap Universal Router
|
||||||
const routerResult = uniswap.decode(
|
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
|
||||||
data,
|
|
||||||
toAddress,
|
|
||||||
chainId,
|
|
||||||
decimalsSources,
|
|
||||||
fromAddress,
|
|
||||||
);
|
|
||||||
if (routerResult) return routerResult;
|
if (routerResult) return routerResult;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -232,11 +213,10 @@ function showTxFee(approvedTx) {
|
|||||||
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
||||||
// Through formatFee(), as the confirmation screen's fee is, so the same
|
// Through formatFee(), as the confirmation screen's fee is, so the same
|
||||||
// fee reads the same on both. In the native currency of the network shown
|
// fee reads the same on both. In the native currency of the network shown
|
||||||
// above, and at the price on it, as the value is.
|
// above, as the value is.
|
||||||
$("approve-tx-fee").textContent = formatFee(
|
$("approve-tx-fee").textContent = formatFee(
|
||||||
gasLimit * feePerGas,
|
gasLimit * feePerGas,
|
||||||
nativeCurrencyByChainId(approvedTx.chainId),
|
nativeCurrencyByChainId(approvedTx.chainId),
|
||||||
approvedTx.chainId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let detail =
|
let detail =
|
||||||
@@ -269,7 +249,10 @@ function showTxApproval(details) {
|
|||||||
|
|
||||||
const toAddr = approvedTx.to;
|
const toAddr = approvedTx.to;
|
||||||
const ethValue = formatEther(approvedTx.value || "0");
|
const ethValue = formatEther(approvedTx.value || "0");
|
||||||
const sources = tokenSourcesByChainId(approvedTx.chainId);
|
const sources = {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
};
|
||||||
|
|
||||||
// Build txInfo for status screens
|
// Build txInfo for status screens
|
||||||
pendingTxDetails = {
|
pendingTxDetails = {
|
||||||
@@ -281,15 +264,8 @@ function showTxApproval(details) {
|
|||||||
chainId: approvedTx.chainId,
|
chainId: approvedTx.chainId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this is an ERC-20 call, try to extract the real recipient and amount.
|
// If this is an ERC-20 call, try to extract the real recipient and amount
|
||||||
// Decoded for the network the transaction is for, which the Network line
|
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
|
||||||
// names, and the address the From line names.
|
|
||||||
const decoded = decodeCalldata(
|
|
||||||
approvedTx.data,
|
|
||||||
toAddr || "",
|
|
||||||
approvedTx.chainId,
|
|
||||||
details.approvedFrom,
|
|
||||||
);
|
|
||||||
if (decoded && decoded.details) {
|
if (decoded && decoded.details) {
|
||||||
// The asset the status summary is counted in: an ERC-20 call's Token
|
// The asset the status summary is counted in: an ERC-20 call's Token
|
||||||
// contract, or a swap's input token. Its symbol is resolved from the
|
// contract, or a swap's input token. Its symbol is resolved from the
|
||||||
@@ -318,23 +294,6 @@ function showTxApproval(details) {
|
|||||||
sources,
|
sources,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// A swap the decoder read no amount for, such as one whose V4 swap
|
|
||||||
// action could not be read or a V4 exact-out swap. The wait and error
|
|
||||||
// screens say the amount could not be decoded, with no currency after
|
|
||||||
// it, rather than show the transaction's ETH value as the amount. A
|
|
||||||
// swap paid in native ETH keeps that value: it is the ETH the swap
|
|
||||||
// pays (for an exact-out swap, the most it pays), not a figure that
|
|
||||||
// had to be read from the calldata.
|
|
||||||
const tokenIn = decoded.details.find((d) => d.label === "Token In");
|
|
||||||
if (
|
|
||||||
tokenIn &&
|
|
||||||
tokenIn.value !== "ETH (native)" &&
|
|
||||||
!decoded.details.some((d) => d.label === "Amount")
|
|
||||||
) {
|
|
||||||
pendingTxDetails.amount = "Unknown (could not be decoded)";
|
|
||||||
pendingTxDetails.token = null;
|
|
||||||
pendingTxDetails.tokenSymbol = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carry decoded calldata info through to success/error views
|
// Carry decoded calldata info through to success/error views
|
||||||
@@ -346,40 +305,32 @@ function showTxApproval(details) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every address link on this screen opens the explorer of the network the
|
|
||||||
// transaction is for, which the Network line names, whichever network is
|
|
||||||
// active.
|
|
||||||
$("approve-tx-origin").textContent = details.origin;
|
$("approve-tx-origin").textContent = details.origin;
|
||||||
$("approve-tx-from").innerHTML = approvalAddressHtml(
|
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
||||||
details.approvedFrom,
|
|
||||||
approvedTx.chainId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show token symbol next to contract address if known on the network the
|
// Show token symbol next to contract address if known
|
||||||
// transaction is for
|
const symbol = toAddr ? tokenLabel(toAddr) : null;
|
||||||
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
|
|
||||||
if (toAddr) {
|
if (toAddr) {
|
||||||
let toHtml = "";
|
let toHtml = "";
|
||||||
if (symbol) {
|
if (symbol) {
|
||||||
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
|
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
|
||||||
}
|
}
|
||||||
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId);
|
toHtml += approvalAddressHtml(toAddr);
|
||||||
$("approve-tx-to").innerHTML = toHtml;
|
$("approve-tx-to").innerHTML = toHtml;
|
||||||
} else {
|
} else {
|
||||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ethValueFormatted = formatTxValue(ethValue);
|
const ethValueFormatted = formatTxValue(
|
||||||
// Priced from the exact value, not the four-decimal figure, at the price
|
formatEther(approvedTx.value || "0"),
|
||||||
// on the network the transaction is for, as the confirmation screen prices
|
);
|
||||||
// its amount, so the same value reads the same on both.
|
const ethPrice = getPrice("ETH");
|
||||||
const ethPrice = getPrice("ETH", approvedTx.chainId);
|
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||||
const ethUsd = ethPrice ? parseFloat(ethValue) * ethPrice : null;
|
|
||||||
const usdStr = formatUsd(ethUsd);
|
const usdStr = formatUsd(ethUsd);
|
||||||
// In the native currency of the network the transaction is for, which the
|
// In the native currency of the network the transaction is for, which the
|
||||||
// Network line names, not the active network's: the active network can
|
// Network line names, not the active network's: a site can switch the
|
||||||
// change, in Settings or when the user approves a site's request, after
|
// active network after this transaction is prepared and back before it is
|
||||||
// this transaction is prepared and change back before it is signed.
|
// signed.
|
||||||
$("approve-tx-value").textContent =
|
$("approve-tx-value").textContent =
|
||||||
ethValueFormatted +
|
ethValueFormatted +
|
||||||
" " +
|
" " +
|
||||||
@@ -401,16 +352,9 @@ function showTxApproval(details) {
|
|||||||
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
|
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
|
||||||
if (d.address) {
|
if (d.address) {
|
||||||
if (d.isToken) {
|
if (d.isToken) {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
|
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
|
||||||
detailsHtml += blockscoutOffTokenNoteHtml(
|
|
||||||
d.address,
|
|
||||||
approvedTx.chainId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
detailsHtml += approvalAddressHtml(
|
detailsHtml += approvalAddressHtml(d.address);
|
||||||
d.address,
|
|
||||||
approvedTx.chainId,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||||
}
|
}
|
||||||
@@ -484,12 +428,21 @@ function markInvisibleCharacters(text) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The type ethers will sign typed data as. ethers does not read the page's
|
||||||
|
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||||
|
// refers to. Throws when the types name no such single struct, which ethers
|
||||||
|
// would refuse to sign as well.
|
||||||
|
function signedPrimaryType(types) {
|
||||||
|
const structs = { ...types };
|
||||||
|
// ethers derives EIP712Domain itself and rejects it as an input.
|
||||||
|
delete structs.EIP712Domain;
|
||||||
|
return TypedDataEncoder.getPrimaryType(structs);
|
||||||
|
}
|
||||||
|
|
||||||
// Why a signature request cannot be signed, as a sentence for the error line,
|
// Why a signature request cannot be signed, as a sentence for the error line,
|
||||||
// or null when it can. Only typed data is refused: the `primaryType` the page
|
// or null when it can. Only typed data is refused: the `primaryType` the page
|
||||||
// states has to be the type ethers will sign, or this screen would name one
|
// states has to be the type ethers will sign, or this screen would name one
|
||||||
// message while another is signed. Its message has to hold every declared
|
// message while another is signed.
|
||||||
// field, with each `bool` true or false, and its domain may name no chain but
|
|
||||||
// the active network's.
|
|
||||||
function typedDataRefusal(sp) {
|
function typedDataRefusal(sp) {
|
||||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
||||||
let data;
|
let data;
|
||||||
@@ -512,10 +465,7 @@ function typedDataRefusal(sp) {
|
|||||||
", so it cannot be signed."
|
", so it cannot be signed."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return null;
|
||||||
typedDataMessageRefusal(data.types, signed, data.message, "") ||
|
|
||||||
typedDataChainRefusal(data.domain, currentNetwork())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
||||||
@@ -628,7 +578,10 @@ function permitWarningHtml(types, primaryType, domain, message) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = tokenSourcesByChainId();
|
const sources = {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
};
|
||||||
const spender = addressOrNull(
|
const spender = addressOrNull(
|
||||||
declaredField(types, primaryType, message, "spender")?.value,
|
declaredField(types, primaryType, message, "spender")?.value,
|
||||||
);
|
);
|
||||||
@@ -657,7 +610,6 @@ function permitWarningHtml(types, primaryType, domain, message) {
|
|||||||
html += `<div class="mb-2"><div>Token</div>`;
|
html += `<div class="mb-2"><div>Token</div>`;
|
||||||
if (token) {
|
if (token) {
|
||||||
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
||||||
html += blockscoutOffTokenNoteHtml(token);
|
|
||||||
html += approvalAddressHtml(token);
|
html += approvalAddressHtml(token);
|
||||||
} else {
|
} else {
|
||||||
html += `<div>Unknown</div>`;
|
html += `<div>Unknown</div>`;
|
||||||
@@ -669,38 +621,10 @@ function permitWarningHtml(types, primaryType, domain, message) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What ethers signs of a message value of type `type`: a struct keeps only
|
|
||||||
// the fields its type declares, in the type's order, and a list is read item
|
|
||||||
// by item. ethers drops every other key, so each one is added to `notSigned`
|
|
||||||
// with its place in the message and its value, for the screen to list apart.
|
|
||||||
function signedValue(types, type, value, path, notSigned) {
|
|
||||||
const itemType = type.replace(/\[\d*\]$/, "");
|
|
||||||
if (itemType !== type) {
|
|
||||||
if (!Array.isArray(value)) return value;
|
|
||||||
return value.map((item, i) =>
|
|
||||||
signedValue(types, itemType, item, `${path}[${i}]`, notSigned),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fields = types[type];
|
|
||||||
if (!fields || !value || typeof value !== "object") return value;
|
|
||||||
const at = (name) => (path ? `${path}.${name}` : name);
|
|
||||||
for (const [key, val] of Object.entries(value)) {
|
|
||||||
if (!fields.some((f) => f.name === key)) notSigned.push([at(key), val]);
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
fields.map((f) => [
|
|
||||||
f.name,
|
|
||||||
signedValue(types, f.type, value[f.name], at(f.name), notSigned),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The typed data as the screen shows it. The primary type shown is the one
|
// The typed data as the screen shows it. The primary type shown is the one
|
||||||
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
|
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
|
||||||
// from differing on anything that can be signed. The Message section shows
|
// from differing on anything that can be signed. Only typed data that cannot
|
||||||
// only what is signed, and any other key the page sent is listed after it as
|
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
|
||||||
// not part of the signature. Only typed data that cannot be read at all is
|
|
||||||
// shown as raw text, and typedDataRefusal() refuses it.
|
|
||||||
function formatTypedDataHtml(jsonStr) {
|
function formatTypedDataHtml(jsonStr) {
|
||||||
let data;
|
let data;
|
||||||
let primaryType;
|
let primaryType;
|
||||||
@@ -734,30 +658,12 @@ function formatTypedDataHtml(jsonStr) {
|
|||||||
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
||||||
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
||||||
|
|
||||||
const lineHtml = (key, val) =>
|
|
||||||
`<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
|
||||||
|
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
const notSigned = [];
|
|
||||||
const message = signedValue(
|
|
||||||
data.types,
|
|
||||||
primaryType,
|
|
||||||
data.message,
|
|
||||||
"",
|
|
||||||
notSigned,
|
|
||||||
);
|
|
||||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||||
for (const [key, val] of Object.entries(message)) {
|
for (const [key, val] of Object.entries(data.message)) {
|
||||||
html += lineHtml(key, val);
|
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
if (notSigned.length > 0) {
|
|
||||||
html += `<div class="mb-2"><div class="text-muted">Not part of the signature</div>`;
|
|
||||||
for (const [key, val] of notSigned) {
|
|
||||||
html += lineHtml(key, val);
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
@@ -814,7 +720,7 @@ function showSignApproval(details) {
|
|||||||
"This message is plain text, not hex, so it cannot be signed.";
|
"This message is plain text, not hex, so it cannot be signed.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The warning the background attaches to an eth_sign request.
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
const warningEl = $("approve-sign-danger-warning");
|
const warningEl = $("approve-sign-danger-warning");
|
||||||
if (warningEl) {
|
if (warningEl) {
|
||||||
if (sp.dangerWarning) {
|
if (sp.dangerWarning) {
|
||||||
@@ -846,29 +752,9 @@ function showSignApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNetworkApproval(details) {
|
|
||||||
showPhishingWarning(
|
|
||||||
"approve-network-phishing-warning",
|
|
||||||
details.isPhishingDomain,
|
|
||||||
);
|
|
||||||
$("approve-network-origin").textContent = details.origin;
|
|
||||||
$("approve-network-current").textContent = networkById(
|
|
||||||
details.currentNetworkId,
|
|
||||||
).name;
|
|
||||||
$("approve-network-requested").textContent = networkById(
|
|
||||||
details.requestedNetworkId,
|
|
||||||
).name;
|
|
||||||
showView("approve-network");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Awaited by nobody: the popup entry point calls this and moves on. It
|
// Awaited by nobody: the popup entry point calls this and moves on. It
|
||||||
// therefore has to absorb its own failure, and a background that cannot
|
// therefore has to absorb its own failure, and a background that cannot
|
||||||
// describe the approval is the same outcome as an approval that is gone.
|
// describe the approval is the same outcome as an approval that is gone.
|
||||||
//
|
|
||||||
// Nothing is on screen until the background has described the approval, so
|
|
||||||
// the screen shown is always the one for the approval this window answers:
|
|
||||||
// the connection prompt's "Allow" and the network switch prompt's "Switch"
|
|
||||||
// answer on the same port, and either would approve the other.
|
|
||||||
async function show(id) {
|
async function show(id) {
|
||||||
approvalId = id;
|
approvalId = id;
|
||||||
approvalPort = runtimeApi().connect({ name: "approval:" + id });
|
approvalPort = runtimeApi().connect({ name: "approval:" + id });
|
||||||
@@ -892,10 +778,6 @@ async function show(id) {
|
|||||||
showSignApproval(details);
|
showSignApproval(details);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (details.type === "network") {
|
|
||||||
showNetworkApproval(details);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Site connection approval
|
// Site connection approval
|
||||||
showPhishingWarning(
|
showPhishingWarning(
|
||||||
"approve-site-phishing-warning",
|
"approve-site-phishing-warning",
|
||||||
@@ -905,7 +787,6 @@ async function show(id) {
|
|||||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||||
attachCopyHandlers("view-approve-site");
|
attachCopyHandlers("view-approve-site");
|
||||||
$("approve-remember").checked = state.rememberSiteChoice;
|
$("approve-remember").checked = state.rememberSiteChoice;
|
||||||
showView("approve-site");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let approvalId = null;
|
let approvalId = null;
|
||||||
@@ -956,15 +837,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locate the wallet and the stored address record owning an address. Returns
|
// Locate the wallet and the address index owning an address. Returns null when
|
||||||
// null when no wallet holds it. Approvals look up the address they were raised
|
// no wallet holds it. Approvals look up the address they were raised for, not
|
||||||
// for, not whichever address is active now: the approval named one account,
|
// whichever address is active now: the approval named one account, and signing
|
||||||
// and signing with another is what verification refuses.
|
// with another is what verification refuses.
|
||||||
function findWalletFor(address) {
|
function findWalletFor(address) {
|
||||||
for (const wallet of state.wallets) {
|
for (const wallet of state.wallets) {
|
||||||
for (let i = 0; i < wallet.addresses.length; i++) {
|
for (let i = 0; i < wallet.addresses.length; i++) {
|
||||||
if (wallet.addresses[i].address === address) {
|
if (wallet.addresses[i].address === address) {
|
||||||
return { wallet, addr: wallet.addresses[i] };
|
return { wallet, addrIndex: i };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -985,21 +866,20 @@ function clearSignPassword() {
|
|||||||
hideError("approve-sign-error");
|
hideError("approve-sign-error");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answer a site-connection or network-switch approval and close. The decision
|
// Answer a site-connection approval and close. The decision goes out on the
|
||||||
// goes out on the approval port — see approvalPort above for why — and carries
|
// approval port — see approvalPort above for why — and carries no approval id,
|
||||||
// no approval id, because the port name already names the approval the
|
// because the port name already names the approval the background will settle.
|
||||||
// background will settle. `remember` means something only for a site
|
// The post is guarded because a throw must not cost the close: posting on a
|
||||||
// connection. The post is guarded because a throw must not cost the close:
|
// port whose background worker has been torn down throws, and the approval it
|
||||||
// posting on a port whose background worker has been torn down throws, and the
|
// would have settled died with that worker, so the only thing left to do is
|
||||||
// approval it would have settled died with that worker, so the only thing left
|
// what the user asked for — go away.
|
||||||
// to do is what the user asked for — go away.
|
function decideSite(approved) {
|
||||||
function decide(approved, remember) {
|
|
||||||
if (approvalPort) {
|
if (approvalPort) {
|
||||||
try {
|
try {
|
||||||
approvalPort.postMessage({
|
approvalPort.postMessage({
|
||||||
type: "AUTISTMASK_APPROVAL_DECISION",
|
type: "AUTISTMASK_APPROVAL_DECISION",
|
||||||
approved,
|
approved,
|
||||||
remember,
|
remember: $("approve-remember").checked,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Nothing to report it to; the window closes either way.
|
// Nothing to report it to; the window closes either way.
|
||||||
@@ -1018,19 +898,11 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("btn-approve").addEventListener("click", () => {
|
$("btn-approve").addEventListener("click", () => {
|
||||||
decide(true, $("approve-remember").checked);
|
decideSite(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-reject").addEventListener("click", () => {
|
$("btn-reject").addEventListener("click", () => {
|
||||||
decide(false, $("approve-remember").checked);
|
decideSite(false);
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-approve-network").addEventListener("click", () => {
|
|
||||||
decide(true, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-reject-network").addEventListener("click", () => {
|
|
||||||
decide(false, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-approve-tx").addEventListener("click", async () => {
|
$("btn-approve-tx").addEventListener("click", async () => {
|
||||||
@@ -1091,7 +963,7 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(
|
const signer = getSignerForAddress(
|
||||||
active.wallet,
|
active.wallet,
|
||||||
active.addr,
|
active.addrIndex,
|
||||||
decryptedSecret,
|
decryptedSecret,
|
||||||
);
|
);
|
||||||
// Sign the approved transaction exactly as it was displayed. The
|
// Sign the approved transaction exactly as it was displayed. The
|
||||||
@@ -1142,21 +1014,12 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The window closes only once the background has the rejection. A message
|
$("btn-reject-tx").addEventListener("click", () => {
|
||||||
// sent as the window closes can be lost, and a closed window does not
|
notify({
|
||||||
// reject the request. After Confirm, the background refuses a rejection
|
|
||||||
// while the transaction is being sent. The window then stays open and
|
|
||||||
// shows the background's message.
|
|
||||||
$("btn-reject-tx").addEventListener("click", async () => {
|
|
||||||
const response = await sendMessage({
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: false,
|
approved: false,
|
||||||
}).catch(() => null);
|
});
|
||||||
if (response && response.error) {
|
|
||||||
showError("approve-tx-error", response.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1228,7 +1091,7 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(
|
const signer = getSignerForAddress(
|
||||||
active.wallet,
|
active.wallet,
|
||||||
active.addr,
|
active.addrIndex,
|
||||||
decryptedSecret,
|
decryptedSecret,
|
||||||
);
|
);
|
||||||
const sp = pendingSignParams;
|
const sp = pendingSignParams;
|
||||||
@@ -1279,18 +1142,12 @@ function init(_ctx) {
|
|||||||
if (outcome.retryable) setSignButtonBusy(false);
|
if (outcome.retryable) setSignButtonBusy(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// As for the transaction's Reject above. After Sign, the background
|
$("btn-reject-sign").addEventListener("click", () => {
|
||||||
// refuses a rejection while the signature is being checked.
|
notify({
|
||||||
$("btn-reject-sign").addEventListener("click", async () => {
|
|
||||||
const response = await sendMessage({
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: false,
|
approved: false,
|
||||||
}).catch(() => null);
|
});
|
||||||
if (response && response.error) {
|
|
||||||
showError("approve-sign-error", response.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-273
@@ -2,13 +2,7 @@
|
|||||||
// Shows transaction details, warnings, errors. On Sign & Send,
|
// Shows transaction details, warnings, errors. On Sign & Send,
|
||||||
// reads inline password, decrypts secret, signs and broadcasts.
|
// reads inline password, decrypts secret, signs and broadcasts.
|
||||||
|
|
||||||
const {
|
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
|
||||||
parseEther,
|
|
||||||
parseUnits,
|
|
||||||
formatEther,
|
|
||||||
formatUnits,
|
|
||||||
Contract,
|
|
||||||
} = require("ethers");
|
|
||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
showError,
|
showError,
|
||||||
@@ -17,16 +11,15 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
nativeCurrency,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
blockieHtml,
|
blockieHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
formatFee,
|
||||||
reviewedNetworkProvider,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { walletDefect } = require("../../shared/walletDefects");
|
const { walletDefect } = require("../../shared/walletDefects");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
@@ -39,12 +32,11 @@ const {
|
|||||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
||||||
const {
|
const {
|
||||||
displayedDecimals,
|
displayedDecimals,
|
||||||
toDecimals,
|
|
||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
} = require("../../shared/transferAmount");
|
} = require("../../shared/transferAmount");
|
||||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||||
// The amount, the balance line and the fee reserve go through it, as the
|
// The balance lines, the fee reserve and the insufficient-balance messages go
|
||||||
// approval screen's amounts do.
|
// through it, as the approval screen's amounts do.
|
||||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||||
const {
|
const {
|
||||||
CODES,
|
CODES,
|
||||||
@@ -68,13 +60,6 @@ let feeWei = null;
|
|||||||
// amount was derived from. Null for any other send, which ethers prices from
|
// amount was derived from. Null for any other send, which ethers prices from
|
||||||
// the node at signing time.
|
// the node at signing time.
|
||||||
let maxSendFees = null;
|
let maxSendFees = null;
|
||||||
// The From address's balances on the network this screen is drawn for, read
|
|
||||||
// from that network's node by readBalances() each time the screen is shown,
|
|
||||||
// so a screen the popup reopens onto states them as they are then, not as
|
|
||||||
// Send knew them. Null until the read returns; then `eth` and `token` are
|
|
||||||
// decimal strings, `token` null for an ETH send or a token whose scale is
|
|
||||||
// unknown, and both null if the read failed.
|
|
||||||
let balances = null;
|
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -83,11 +68,11 @@ function restore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmAddressHtml(address, ensName, title, chainId) {
|
function confirmAddressHtml(address, ensName, title) {
|
||||||
const blockie = blockieHtml(address);
|
const blockie = blockieHtml(address);
|
||||||
return (
|
return (
|
||||||
`<div class="mb-1">${blockie}</div>` +
|
`<div class="mb-1">${blockie}</div>` +
|
||||||
renderAddressHtml(address, { title, ensName, chainId })
|
renderAddressHtml(address, { title, ensName })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,96 +83,30 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The amount as this screen and the wait, success and error screens show it,
|
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
||||||
// written as the approval screen writes a site's amount: the amount being
|
// once its amount is re-derived from the fee estimate.
|
||||||
// signed, parsed at the scale it is encoded with and written back as plain
|
|
||||||
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
|
|
||||||
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
|
|
||||||
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
|
|
||||||
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
|
|
||||||
function shownAmount(txInfo) {
|
|
||||||
let exact = txInfo.amount;
|
|
||||||
try {
|
|
||||||
if (txInfo.token === "ETH") {
|
|
||||||
exact = formatEther(parseEther(txInfo.amount));
|
|
||||||
} else {
|
|
||||||
const decimals = displayedDecimals(txInfo.tokenDecimals);
|
|
||||||
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// A token whose scale is unknown, which is never signed, or an amount
|
|
||||||
// the amount check refuses: cut as Send's amount field held it.
|
|
||||||
}
|
|
||||||
return truncateAmountNeverZero(exact);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Amount line, with its USD value, which is priced from the exact amount.
|
|
||||||
// A max ETH send's line is drawn again once its amount is re-derived from the
|
|
||||||
// fee estimate.
|
|
||||||
function renderAmount(txInfo) {
|
function renderAmount(txInfo) {
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
const rawSymbol = isErc20
|
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||||
? txInfo.tokenSymbol || "?"
|
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
|
||||||
: nativeCurrencyByChainId(txInfo.chainId);
|
|
||||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
|
||||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
txInfo.amount + " " + displaySymbol(rawSymbol),
|
||||||
amountUsd,
|
amountUsd,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The balance line, with its USD value: "Loading..." until readBalances()
|
|
||||||
// returns, then the balance it read.
|
|
||||||
function renderBalance(txInfo) {
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
|
||||||
const rawSymbol = isErc20
|
|
||||||
? txInfo.tokenSymbol || "?"
|
|
||||||
: nativeCurrencyByChainId(txInfo.chainId);
|
|
||||||
const symbol = displaySymbol(rawSymbol);
|
|
||||||
if (balances === null) {
|
|
||||||
$("confirm-balance").textContent = "Loading...";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A null balance is one nobody established, for a token whose scale is
|
|
||||||
// unknown or after a read that failed, and is stated as unknown, never as
|
|
||||||
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
||||||
const bal = isErc20 ? balances.token : balances.eth;
|
|
||||||
if (bal === null) {
|
|
||||||
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
|
||||||
const balUsd = price ? parseFloat(bal) * price : null;
|
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
|
||||||
truncateAmountNeverZero(bal) + " " + symbol,
|
|
||||||
balUsd,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = txInfo;
|
pendingTx = txInfo;
|
||||||
feeStatus = FEE_PENDING;
|
feeStatus = FEE_PENDING;
|
||||||
feeWei = null;
|
feeWei = null;
|
||||||
maxSendFees = null;
|
maxSendFees = null;
|
||||||
balances = null;
|
|
||||||
// The network this screen is drawn for, and the only one it sends on: the
|
|
||||||
// one active when Send handed the transaction over. Drawn again from the
|
|
||||||
// saved transaction, as when the popup reopens onto this screen, it keeps
|
|
||||||
// that network, so Sign & Send refuses once Settings has switched to
|
|
||||||
// another. Everything this screen shows is that network's: native
|
|
||||||
// currency, USD values, explorer links, the balance, and the node and
|
|
||||||
// explorer its checks ask. The wait, success and error screens label
|
|
||||||
// amounts by it.
|
|
||||||
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
|
|
||||||
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
// The raw symbol is the price-table key; the capped one is what the
|
// The raw symbol is the price-table key; the capped one is what the
|
||||||
// screen says. Truncating before the lookup would silently drop the
|
// screen says. Truncating before the lookup would silently drop the
|
||||||
// price of any token whose symbol is long enough to be capped.
|
// price of any token whose symbol is long enough to be capped.
|
||||||
const rawSymbol = isErc20
|
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||||
? txInfo.tokenSymbol || "?"
|
|
||||||
: nativeCurrencyByChainId(txInfo.chainId);
|
|
||||||
const symbol = displaySymbol(rawSymbol);
|
const symbol = displaySymbol(rawSymbol);
|
||||||
|
|
||||||
// Transaction type
|
// Transaction type
|
||||||
@@ -203,7 +122,7 @@ function show(txInfo) {
|
|||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
$("confirm-token-contract").innerHTML = renderAddressHtml(
|
$("confirm-token-contract").innerHTML = renderAddressHtml(
|
||||||
txInfo.token,
|
txInfo.token,
|
||||||
{ chainId: txInfo.chainId },
|
{},
|
||||||
);
|
);
|
||||||
tokenSection.classList.remove("hidden");
|
tokenSection.classList.remove("hidden");
|
||||||
attachCopyHandlers(tokenSection);
|
attachCopyHandlers(tokenSection);
|
||||||
@@ -217,7 +136,6 @@ function show(txInfo) {
|
|||||||
txInfo.from,
|
txInfo.from,
|
||||||
null,
|
null,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
txInfo.chainId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// To (with blockie)
|
// To (with blockie)
|
||||||
@@ -226,12 +144,37 @@ function show(txInfo) {
|
|||||||
txInfo.to,
|
txInfo.to,
|
||||||
txInfo.ensName,
|
txInfo.ensName,
|
||||||
toTitle,
|
toTitle,
|
||||||
txInfo.chainId,
|
|
||||||
);
|
);
|
||||||
$("confirm-to-ens").classList.add("hidden");
|
$("confirm-to-ens").classList.add("hidden");
|
||||||
|
|
||||||
renderAmount(txInfo);
|
renderAmount(txInfo);
|
||||||
renderBalance(txInfo);
|
|
||||||
|
// Balance (with inline USD)
|
||||||
|
const ethPrice = getPrice("ETH");
|
||||||
|
const tokenPrice = getPrice(rawSymbol);
|
||||||
|
if (isErc20) {
|
||||||
|
// null is a balance whose scale nothing knows, not a balance of zero
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||||
|
// refused at encode time for the same missing scale; what this line
|
||||||
|
// must not do is state a quantity nobody established.
|
||||||
|
const bal = txInfo.tokenBalance;
|
||||||
|
const balUsd =
|
||||||
|
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
|
||||||
|
$("confirm-balance").textContent =
|
||||||
|
bal == null
|
||||||
|
? "unknown (" + symbol + ")"
|
||||||
|
: valueWithUsd(
|
||||||
|
truncateAmountNeverZero(bal) + " " + symbol,
|
||||||
|
balUsd,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const bal = txInfo.balance || "0";
|
||||||
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||||
|
$("confirm-balance").textContent = valueWithUsd(
|
||||||
|
truncateAmountNeverZero(bal) + " " + symbol,
|
||||||
|
balUsd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for warnings (synchronous local checks)
|
// Check for warnings (synchronous local checks)
|
||||||
const localWarnings = getLocalWarnings(txInfo.to, {
|
const localWarnings = getLocalWarnings(txInfo.to, {
|
||||||
@@ -265,22 +208,15 @@ function show(txInfo) {
|
|||||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||||
$("confirm-gas-error").textContent =
|
$("confirm-gas-error").textContent =
|
||||||
"You do not have enough " +
|
"You do not have enough " +
|
||||||
nativeCurrencyByChainId(txInfo.chainId) +
|
nativeCurrency() +
|
||||||
" to pay the network fee for this transfer. Please add " +
|
" to pay the network fee for this transfer. Please add " +
|
||||||
nativeCurrencyByChainId(txInfo.chainId) +
|
nativeCurrency() +
|
||||||
" to this address and try again.";
|
" to this address and try again.";
|
||||||
|
|
||||||
// Both shown later, by checkRecipientHistory(). The first sentence says
|
// Shown later, once checkRecipientHistory() finds a contract.
|
||||||
// exactly what getFullWarnings() checks.
|
|
||||||
$("confirm-recipient-warning").textContent =
|
|
||||||
"WARNING: The recipient address has never sent a transaction and" +
|
|
||||||
" holds no " +
|
|
||||||
nativeCurrencyByChainId(txInfo.chainId) +
|
|
||||||
". This may indicate a fresh or unused address. Double-check the" +
|
|
||||||
" address before sending.";
|
|
||||||
$("confirm-contract-warning").textContent =
|
$("confirm-contract-warning").textContent =
|
||||||
"WARNING: The recipient is a smart contract. Sending " +
|
"WARNING: The recipient is a smart contract. Sending " +
|
||||||
nativeCurrencyByChainId(txInfo.chainId) +
|
nativeCurrency() +
|
||||||
" or tokens directly to a contract may result in permanent loss of" +
|
" or tokens directly to a contract may result in permanent loss of" +
|
||||||
" funds.";
|
" funds.";
|
||||||
|
|
||||||
@@ -297,29 +233,11 @@ function show(txInfo) {
|
|||||||
" cannot be checked against your balance. Please go back and" +
|
" cannot be checked against your balance. Please go back and" +
|
||||||
" try again.";
|
" try again.";
|
||||||
|
|
||||||
// So does the message for an amount the balance does not cover. It is
|
|
||||||
// set here, before the first paint, so the balance read returning only
|
|
||||||
// makes it visible and moves nothing, which is why it quotes no balance.
|
|
||||||
// Without the token's scale readBalances() reads no token balance.
|
|
||||||
$("confirm-balance-error").textContent =
|
|
||||||
isErc20 && toDecimals(txInfo.tokenDecimals) === null
|
|
||||||
? "This token's balance is unknown, because nothing this wallet" +
|
|
||||||
" can consult reports how many decimal places it uses, so the" +
|
|
||||||
" amount you are trying to send cannot be checked against it."
|
|
||||||
: "Your " +
|
|
||||||
symbol +
|
|
||||||
" balance does not cover this amount. Please go back and send" +
|
|
||||||
" a smaller amount.";
|
|
||||||
|
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
|
|
||||||
// Reset password field and error, and any refusal of an earlier Sign &
|
// Reset password field and error
|
||||||
// Send.
|
|
||||||
$("confirm-tx-password").value = "";
|
$("confirm-tx-password").value = "";
|
||||||
hideError("confirm-tx-password-error");
|
hideError("confirm-tx-password-error");
|
||||||
setVisible("confirm-network-changed-error", false);
|
|
||||||
setVisible("confirm-from-address-error", false);
|
|
||||||
setVisible("confirm-fee-bound-error", false);
|
|
||||||
|
|
||||||
// Gas estimate — show placeholder then fetch async
|
// Gas estimate — show placeholder then fetch async
|
||||||
$("confirm-fee").style.visibility = "visible";
|
$("confirm-fee").style.visibility = "visible";
|
||||||
@@ -334,89 +252,74 @@ function show(txInfo) {
|
|||||||
$("confirm-contract-warning").style.visibility = "hidden";
|
$("confirm-contract-warning").style.visibility = "hidden";
|
||||||
$("confirm-burn-warning").style.visibility = "hidden";
|
$("confirm-burn-warning").style.visibility = "hidden";
|
||||||
$("confirm-etherscan-warning").style.visibility = "hidden";
|
$("confirm-etherscan-warning").style.visibility = "hidden";
|
||||||
// While "Check recipients on Etherscan" is off, there is no Etherscan
|
|
||||||
// warning to reserve space for. A line in its place says the check is off.
|
|
||||||
const etherscanOn = !!state.checkRecipientsOnEtherscan;
|
|
||||||
$("confirm-etherscan-warning").classList.toggle("hidden", !etherscanOn);
|
|
||||||
$("confirm-etherscan-note").classList.toggle("hidden", etherscanOn);
|
|
||||||
|
|
||||||
// Show burn warning via reserved element (in addition to inline warning)
|
// Show burn warning via reserved element (in addition to inline warning)
|
||||||
if (isBurnAddress(txInfo.to)) {
|
if (isBurnAddress(txInfo.to)) {
|
||||||
$("confirm-burn-warning").style.visibility = "visible";
|
$("confirm-burn-warning").style.visibility = "visible";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fee is estimated once the balances are read, because a max ETH
|
estimateGas(txInfo);
|
||||||
// send's amount is the ETH balance minus the estimate's reserve.
|
|
||||||
readBalances(txInfo).then(() => estimateGas(txInfo));
|
|
||||||
checkRecipientHistory(txInfo);
|
checkRecipientHistory(txInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the From address's balances from the node of the network this
|
|
||||||
// transaction was reviewed for: ETH, and for a token send the token, at the
|
|
||||||
// scale the screen renders the amount with. A token whose scale is unknown has
|
|
||||||
// no balance to read. Never rejects.
|
|
||||||
async function readBalances(txInfo) {
|
|
||||||
let read;
|
|
||||||
try {
|
|
||||||
const provider = reviewedNetworkProvider(txInfo);
|
|
||||||
const decimals = toDecimals(txInfo.tokenDecimals);
|
|
||||||
const readToken = txInfo.token !== "ETH" && decimals !== null;
|
|
||||||
const [ethWei, tokenUnits] = await Promise.all([
|
|
||||||
provider.getBalance(txInfo.from),
|
|
||||||
readToken
|
|
||||||
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
|
|
||||||
txInfo.from,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
]);
|
|
||||||
read = {
|
|
||||||
eth: formatEther(ethWei),
|
|
||||||
token: readToken ? formatUnits(tokenUnits, decimals) : null,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
log.errorf("balance read failed:", e.shortMessage || e.message);
|
|
||||||
read = { eth: null, token: null };
|
|
||||||
}
|
|
||||||
// The user may have left this transaction while the read was in flight.
|
|
||||||
if (pendingTx !== txInfo) return;
|
|
||||||
balances = read;
|
|
||||||
renderBalance(txInfo);
|
|
||||||
renderValidation(txInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the balance check for the transaction on screen. Called once during
|
// Render the balance check for the transaction on screen. Called once during
|
||||||
// show(), again when the balances are read, and again when the fee estimate
|
// show() and again when the fee estimate resolves or fails. Every element it
|
||||||
// resolves or fails. Every element it touches already occupies its space, so
|
// touches already occupies its space, so re-running it never moves anything.
|
||||||
// re-running it never moves anything.
|
|
||||||
function renderValidation(txInfo) {
|
function renderValidation(txInfo) {
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
|
const symbol = isErc20
|
||||||
// Null while the balances are being read and after a read that failed.
|
? displaySymbol(txInfo.tokenSymbol || "?")
|
||||||
// validateTransfer() takes a missing balance as nothing to spend from, so
|
: nativeCurrency();
|
||||||
// sending stays blocked either way, and the only message shown for it is
|
|
||||||
// the one saying the read failed.
|
|
||||||
const ethBalance = balances ? balances.eth : null;
|
|
||||||
const tokenBalance = balances ? balances.token : null;
|
|
||||||
const balanceRead = ethBalance !== null;
|
|
||||||
|
|
||||||
const { canSend, codes } = validateTransfer({
|
const { canSend, codes } = validateTransfer({
|
||||||
isErc20,
|
isErc20,
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
tokenDecimals: txInfo.tokenDecimals,
|
ethBalance: txInfo.balance,
|
||||||
ethBalance,
|
tokenBalance: txInfo.tokenBalance,
|
||||||
tokenBalance,
|
|
||||||
feeStatus,
|
feeStatus,
|
||||||
feeWei,
|
feeWei,
|
||||||
});
|
});
|
||||||
|
|
||||||
// An invalid amount is known before the first paint, so its message is
|
// Messages carrying the user's own numbers are built here; the fixed
|
||||||
// written here. Every message a later result brings has a reserved
|
// sentences live in the reserved elements in index.html, except the
|
||||||
// element, its sentence fixed in index.html or set by show(), and is only
|
// gas and fee-unknown ones, which show() sets.
|
||||||
// made visible below, so nothing moves when the result arrives.
|
|
||||||
const messages = [];
|
const messages = [];
|
||||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||||
messages.push("Please enter a valid amount to send.");
|
messages.push("Please enter a valid amount to send.");
|
||||||
}
|
}
|
||||||
|
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||||
|
messages.push(
|
||||||
|
txInfo.tokenBalance == null
|
||||||
|
? "This token's balance is unknown, because nothing this" +
|
||||||
|
" wallet can consult reports how many decimal places it" +
|
||||||
|
" uses, so the amount you are trying to send cannot be" +
|
||||||
|
" checked against it."
|
||||||
|
: "Insufficient " +
|
||||||
|
symbol +
|
||||||
|
" balance. You have " +
|
||||||
|
truncateAmountNeverZero(txInfo.tokenBalance) +
|
||||||
|
" " +
|
||||||
|
symbol +
|
||||||
|
" but are trying to send " +
|
||||||
|
txInfo.amount +
|
||||||
|
" " +
|
||||||
|
symbol +
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||||
|
messages.push(
|
||||||
|
"Insufficient balance. You have " +
|
||||||
|
truncateAmountNeverZero(txInfo.balance || "0") +
|
||||||
|
" " +
|
||||||
|
symbol +
|
||||||
|
" but are trying to send " +
|
||||||
|
txInfo.amount +
|
||||||
|
" " +
|
||||||
|
symbol +
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const errorsEl = $("confirm-errors");
|
const errorsEl = $("confirm-errors");
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
@@ -429,20 +332,13 @@ function renderValidation(txInfo) {
|
|||||||
errorsEl.style.visibility = "hidden";
|
errorsEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
|
|
||||||
setVisible(
|
|
||||||
"confirm-balance-error",
|
|
||||||
balanceRead &&
|
|
||||||
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
|
|
||||||
codes.includes(CODES.INSUFFICIENT_ETH)),
|
|
||||||
);
|
|
||||||
setVisible(
|
setVisible(
|
||||||
"confirm-amount-fee-error",
|
"confirm-amount-fee-error",
|
||||||
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||||||
);
|
);
|
||||||
setVisible(
|
setVisible(
|
||||||
"confirm-gas-error",
|
"confirm-gas-error",
|
||||||
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
||||||
);
|
);
|
||||||
setVisible(
|
setVisible(
|
||||||
"confirm-fee-unknown-error",
|
"confirm-fee-unknown-error",
|
||||||
@@ -463,7 +359,7 @@ function setVisible(id, visible) {
|
|||||||
|
|
||||||
async function estimateGas(txInfo) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = reviewedNetworkProvider(txInfo);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const feeData = await provider.getFeeData();
|
const feeData = await provider.getFeeData();
|
||||||
let gasLimit;
|
let gasLimit;
|
||||||
|
|
||||||
@@ -511,17 +407,12 @@ async function estimateGas(txInfo) {
|
|||||||
// does, so the same fee reads the same on both.
|
// does, so the same fee reads the same on both.
|
||||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||||
$("confirm-fee-amount").textContent =
|
$("confirm-fee-amount").textContent =
|
||||||
"~" +
|
"~" + formatFee(estimateWei, nativeCurrency());
|
||||||
formatFee(
|
|
||||||
estimateWei,
|
|
||||||
nativeCurrencyByChainId(txInfo.chainId),
|
|
||||||
txInfo.chainId,
|
|
||||||
);
|
|
||||||
$("confirm-fee-reserve").textContent =
|
$("confirm-fee-reserve").textContent =
|
||||||
"up to " +
|
"up to " +
|
||||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
||||||
" " +
|
" " +
|
||||||
nativeCurrencyByChainId(txInfo.chainId) +
|
nativeCurrency() +
|
||||||
" reserved";
|
" reserved";
|
||||||
setVisible("confirm-fee-reserve", true);
|
setVisible("confirm-fee-reserve", true);
|
||||||
} else {
|
} else {
|
||||||
@@ -530,21 +421,20 @@ async function estimateGas(txInfo) {
|
|||||||
// cost is not below the reserve. Show the reserve alone.
|
// cost is not below the reserve. Show the reserve alone.
|
||||||
$("confirm-fee-amount").textContent = formatFee(
|
$("confirm-fee-amount").textContent = formatFee(
|
||||||
gasCostWei,
|
gasCostWei,
|
||||||
nativeCurrencyByChainId(txInfo.chainId),
|
nativeCurrency(),
|
||||||
txInfo.chainId,
|
|
||||||
);
|
);
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
}
|
}
|
||||||
feeStatus = FEE_KNOWN;
|
feeStatus = FEE_KNOWN;
|
||||||
feeWei = gasCostWei;
|
feeWei = gasCostWei;
|
||||||
// A max ETH send is the balance readBalances() read minus this
|
// A max ETH send is the balance minus this estimate's reserve, not the
|
||||||
// estimate's reserve, not the Send screen's, and is signed with this
|
// Send screen's, and is signed with this estimate's fee fields: fees
|
||||||
// estimate's fee fields: fees fetched again at signing could exceed
|
// fetched again at signing could exceed the reserve it leaves, and the
|
||||||
// the reserve it leaves, and the node would refuse it for want of
|
// node would refuse it for want of funds. Where the balance no longer
|
||||||
// funds. Where the balance is unknown or no longer covers the fee, the
|
// covers the fee, the amount is left as it is and the balance check
|
||||||
// amount is left as it is and the balance check below says so.
|
// below says so.
|
||||||
if (txInfo.max && txInfo.token === "ETH") {
|
if (txInfo.max && txInfo.token === "ETH") {
|
||||||
const amount = maxEthAmount(balances?.eth, gasCostWei);
|
const amount = maxEthAmount(txInfo.balance, gasCostWei);
|
||||||
if (amount !== null) {
|
if (amount !== null) {
|
||||||
txInfo.amount = amount;
|
txInfo.amount = amount;
|
||||||
renderAmount(txInfo);
|
renderAmount(txInfo);
|
||||||
@@ -573,17 +463,16 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the transaction this send describes, enforce the bounds against the
|
// Populate the transaction this send describes, enforce the fee bound against
|
||||||
// gas limit and fees that were actually filled in, then sign and broadcast it.
|
// the fees that were actually filled in, then sign and broadcast it. Apart
|
||||||
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
|
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
|
||||||
// the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
|
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
|
||||||
// from what the configured RPC node answers, with nothing otherwise bounding
|
// what the configured RPC node answers, with nothing otherwise bounding what a
|
||||||
// what a hostile node can set — the dApp path's ceilings never reached this
|
// hostile node can set — the dApp path's ceilings never reached this one.
|
||||||
// one. Populating before the check is what makes assertWithinCeilings() see
|
// Populating before the check is what makes assertWithinCeilings() see the
|
||||||
// the same numbers that would be signed; it throws an ApprovalMismatchError
|
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
||||||
// when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
|
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
||||||
// over its bound, and the caller then shows the one refusal reserved for all
|
// shows in the reserved error area rather than sending.
|
||||||
// three rather than sending.
|
|
||||||
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||||
let request;
|
let request;
|
||||||
if (tx.token === "ETH") {
|
if (tx.token === "ETH") {
|
||||||
@@ -601,26 +490,26 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
|||||||
);
|
);
|
||||||
request = await contract.transfer.populateTransaction(tx.to, amount);
|
request = await contract.transfer.populateTransaction(tx.to, amount);
|
||||||
}
|
}
|
||||||
// The chain id of the network the screen was drawn for: ethers refuses to
|
const populated = await connectedSigner.populateTransaction(request);
|
||||||
// populate it for a provider on any other network.
|
|
||||||
const populated = await connectedSigner.populateTransaction({
|
|
||||||
...request,
|
|
||||||
chainId: tx.chainId,
|
|
||||||
});
|
|
||||||
assertWithinCeilings(populated);
|
assertWithinCeilings(populated);
|
||||||
return connectedSigner.sendTransaction(populated);
|
return connectedSigner.sendTransaction(populated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show a full-sentence send failure in the reserved errors box, the same
|
||||||
|
// element and markup renderValidation() uses for messages carrying the user's
|
||||||
|
// own numbers, so it never moves anything on the screen.
|
||||||
|
function showSendError(message) {
|
||||||
|
const el = $("confirm-errors");
|
||||||
|
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
|
||||||
|
el.style.visibility = "visible";
|
||||||
|
}
|
||||||
|
|
||||||
async function checkRecipientHistory(txInfo) {
|
async function checkRecipientHistory(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = reviewedNetworkProvider(txInfo);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
||||||
fromAddress: txInfo.from,
|
fromAddress: txInfo.from,
|
||||||
chainId: txInfo.chainId,
|
|
||||||
});
|
});
|
||||||
// The user may have left this transaction while the checks were in
|
|
||||||
// flight; its warnings must not reach another recipient's screen.
|
|
||||||
if (pendingTx !== txInfo) return;
|
|
||||||
for (const w of asyncWarnings) {
|
for (const w of asyncWarnings) {
|
||||||
if (w.type === "contract") {
|
if (w.type === "contract") {
|
||||||
$("confirm-contract-warning").style.visibility = "visible";
|
$("confirm-contract-warning").style.visibility = "visible";
|
||||||
@@ -649,11 +538,6 @@ function init(_ctx) {
|
|||||||
onViewLeave("confirm-tx", clearPassword);
|
onViewLeave("confirm-tx", clearPassword);
|
||||||
|
|
||||||
$("btn-confirm-send").addEventListener("click", async () => {
|
$("btn-confirm-send").addEventListener("click", async () => {
|
||||||
// A press shows only its own refusal, if any.
|
|
||||||
setVisible("confirm-network-changed-error", false);
|
|
||||||
setVisible("confirm-from-address-error", false);
|
|
||||||
setVisible("confirm-fee-bound-error", false);
|
|
||||||
|
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
const wallet = state.wallets[state.selectedWallet];
|
||||||
// Every Send button refuses a defective wallet before this screen,
|
// Every Send button refuses a defective wallet before this screen,
|
||||||
// but the popup also reopens onto it from a saved view.
|
// but the popup also reopens onto it from a saved view.
|
||||||
@@ -663,24 +547,6 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The screen describes the network the transaction was reviewed for,
|
|
||||||
// which Settings may have switched away from since: "Back" from
|
|
||||||
// Settings after a switch draws this screen again from the saved
|
|
||||||
// transaction, as a popup reopened onto it does.
|
|
||||||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
|
||||||
setVisible("confirm-network-changed-error", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign as the From address this screen shows. The selection is saved
|
|
||||||
// apart from this screen, so the popup can reopen onto it with another
|
|
||||||
// address selected.
|
|
||||||
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
|
|
||||||
if (!addr) {
|
|
||||||
setVisible("confirm-from-address-error", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = $("confirm-tx-password").value;
|
const password = $("confirm-tx-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError(
|
showError(
|
||||||
@@ -709,13 +575,17 @@ function init(_ctx) {
|
|||||||
$("btn-confirm-send").disabled = true;
|
$("btn-confirm-send").disabled = true;
|
||||||
$("btn-confirm-send").classList.add("text-muted");
|
$("btn-confirm-send").classList.add("text-muted");
|
||||||
|
|
||||||
// The wait, success and error screens show the amount as this screen
|
// The network it is sent on. The wait, success and error screens
|
||||||
// does, so they are handed a copy carrying that text. pendingTx keeps
|
// label its amount by this, not by the network active when they draw.
|
||||||
// the exact amount, which is the one signed.
|
pendingTx.chainId = currentNetwork().chainId;
|
||||||
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
|
|
||||||
let tx;
|
let tx;
|
||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
const signer = getSignerForAddress(
|
||||||
|
wallet,
|
||||||
|
state.selectedAddress,
|
||||||
|
decryptedSecret,
|
||||||
|
);
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const connectedSigner = signer.connect(provider);
|
const connectedSigner = signer.connect(provider);
|
||||||
|
|
||||||
@@ -729,23 +599,19 @@ function init(_ctx) {
|
|||||||
// Note: JS strings are immutable; this nulls the reference but
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
// the original string may persist in memory until GC.
|
// the original string may persist in memory until GC.
|
||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
txStatus.showWait(shownTx, tx.hash);
|
txStatus.showWait(pendingTx, tx.hash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
// A gas limit or fee over its bound is refused before anything is
|
// A fee over the bound is refused before anything is broadcast, so
|
||||||
// broadcast, so there is no transaction that may have reached the
|
// there is no transaction that may have reached the network to warn
|
||||||
// network to warn about: the refusal stays on the confirmation
|
// about: the message stays on the confirmation screen where the
|
||||||
// screen where the user can go back, rather than routing to the
|
// user can go back, rather than routing to the sent/failed screen.
|
||||||
// sent/failed screen. Its sentence is fixed in index.html, so that
|
|
||||||
// its box can hold its space from the first paint; the reason in
|
|
||||||
// full, naming the bound, is logged.
|
|
||||||
if (e && e.approvalMismatch) {
|
if (e && e.approvalMismatch) {
|
||||||
log.errorf("send refused:", e.message);
|
showSendError(e.message);
|
||||||
setVisible("confirm-fee-bound-error", true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hash = tx ? tx.hash : null;
|
const hash = tx ? tx.hash : null;
|
||||||
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
|
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||||
} finally {
|
} finally {
|
||||||
$("btn-confirm-send").disabled = false;
|
$("btn-confirm-send").disabled = false;
|
||||||
$("btn-confirm-send").classList.remove("text-muted");
|
$("btn-confirm-send").classList.remove("text-muted");
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const {
|
|||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
keyMaterialName,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||||
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const {
|
const {
|
||||||
canRemoveAddress,
|
canRemoveAddress,
|
||||||
@@ -54,7 +54,9 @@ function setFlash(msg) {
|
|||||||
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
|
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
|
||||||
// this screen is offered on xprv wallets too.
|
// this screen is offered on xprv wallets too.
|
||||||
function recoveryPathText(wallet) {
|
function recoveryPathText(wallet) {
|
||||||
const secret = keyMaterialName(wallet);
|
const secret = walletHasRecoveryPhrase(wallet)
|
||||||
|
? "recovery phrase"
|
||||||
|
: "extended private key";
|
||||||
return (
|
return (
|
||||||
"Getting the address back into this list is not easy, so be sure. " +
|
"Getting the address back into this list is not easy, so be sure. " +
|
||||||
"Adding an address derives the next unused one, not this one, and " +
|
"Adding an address derives the next unused one, not this one, and " +
|
||||||
@@ -119,11 +121,6 @@ function show(walletIdx, addrIdx) {
|
|||||||
|
|
||||||
$("delete-address-recovery").textContent = recoveryPathText(wallet);
|
$("delete-address-recovery").textContent = recoveryPathText(wallet);
|
||||||
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
|
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
|
||||||
// Only Blockscout reports the tokens the user has not tracked.
|
|
||||||
$("delete-address-tokens-note").classList.toggle(
|
|
||||||
"hidden",
|
|
||||||
!!state.useBlockscout,
|
|
||||||
);
|
|
||||||
|
|
||||||
setFlash("");
|
setFlash("");
|
||||||
showView("delete-address-confirm");
|
showView("delete-address-confirm");
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
showError,
|
|
||||||
hideError,
|
|
||||||
goBack,
|
goBack,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
keyMaterialName,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
@@ -16,7 +13,6 @@ const {
|
|||||||
broadcastActiveChanged,
|
broadcastActiveChanged,
|
||||||
} = require("../../shared/walletDelete");
|
} = require("../../shared/walletDelete");
|
||||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||||
const welcome = require("./welcome");
|
|
||||||
|
|
||||||
let deleteWalletIndex = null;
|
let deleteWalletIndex = null;
|
||||||
let lostPasswordIndex = null;
|
let lostPasswordIndex = null;
|
||||||
@@ -59,7 +55,8 @@ function confirmKey(name) {
|
|||||||
function clear() {
|
function clear() {
|
||||||
deleteWalletIndex = null;
|
deleteWalletIndex = null;
|
||||||
$("delete-wallet-password").value = "";
|
$("delete-wallet-password").value = "";
|
||||||
hideError("delete-wallet-password-error");
|
$("delete-wallet-flash").textContent = "";
|
||||||
|
$("delete-wallet-flash").style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The lost-password screen holds no secret — a wallet name is not one —
|
// The lost-password screen holds no secret — a wallet name is not one —
|
||||||
@@ -77,9 +74,6 @@ function show(walletIdx) {
|
|||||||
clear();
|
clear();
|
||||||
deleteWalletIndex = walletIdx;
|
deleteWalletIndex = walletIdx;
|
||||||
$("delete-wallet-name").textContent = displayName(walletIdx);
|
$("delete-wallet-name").textContent = displayName(walletIdx);
|
||||||
$("delete-wallet-key-material").textContent = keyMaterialName(
|
|
||||||
state.wallets[walletIdx],
|
|
||||||
);
|
|
||||||
showView("delete-wallet-confirm");
|
showView("delete-wallet-confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +93,6 @@ function showLostPassword() {
|
|||||||
clearLostPassword();
|
clearLostPassword();
|
||||||
$("delete-wallet-lost-name").textContent = name;
|
$("delete-wallet-lost-name").textContent = name;
|
||||||
$("delete-wallet-lost-name-echo").textContent = name;
|
$("delete-wallet-lost-name-echo").textContent = name;
|
||||||
$("delete-wallet-lost-key-material").textContent = keyMaterialName(
|
|
||||||
state.wallets[walletIdx],
|
|
||||||
);
|
|
||||||
// showView() runs the leave hook of delete-wallet-confirm, which nulls
|
// showView() runs the leave hook of delete-wallet-confirm, which nulls
|
||||||
// deleteWalletIndex, so this screen's own selection is recorded after
|
// deleteWalletIndex, so this screen's own selection is recorded after
|
||||||
// it and not before.
|
// it and not before.
|
||||||
@@ -136,7 +127,7 @@ async function finishDelete(walletIdx) {
|
|||||||
// Save before broadcasting: the background reads the active
|
// Save before broadcasting: the background reads the active
|
||||||
// address back out of storage to build accountsChanged.
|
// address back out of storage to build accountsChanged.
|
||||||
if (activeAddressChanged) broadcastActiveChanged();
|
if (activeAddressChanged) broadcastActiveChanged();
|
||||||
welcome.show();
|
showView("welcome");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,22 +232,19 @@ function init(_ctx) {
|
|||||||
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
||||||
const pw = $("delete-wallet-password").value;
|
const pw = $("delete-wallet-password").value;
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
showError(
|
$("delete-wallet-flash").textContent =
|
||||||
"delete-wallet-password-error",
|
"Please enter your password.";
|
||||||
"Please enter your password.",
|
$("delete-wallet-flash").style.visibility = "visible";
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleteWalletIndex === null) {
|
if (deleteWalletIndex === null) {
|
||||||
showError(
|
$("delete-wallet-flash").textContent =
|
||||||
"delete-wallet-password-error",
|
"No wallet selected for deletion.";
|
||||||
"No wallet selected for deletion.",
|
$("delete-wallet-flash").style.visibility = "visible";
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hideError("delete-wallet-password-error");
|
|
||||||
const btn = $("btn-delete-wallet-confirm");
|
const btn = $("btn-delete-wallet-confirm");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
@@ -268,10 +256,9 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
await decryptWithPassword(wallet.encryptedSecret, pw);
|
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||||
} catch {
|
} catch {
|
||||||
showError(
|
$("delete-wallet-flash").textContent =
|
||||||
"delete-wallet-password-error",
|
"That password is incorrect. Please try again.";
|
||||||
"That password is incorrect. Please try again.",
|
$("delete-wallet-flash").style.visibility = "visible";
|
||||||
);
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
showError,
|
|
||||||
hideError,
|
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -58,6 +56,11 @@ function isCurrentReveal(generation) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
$("export-privkey-flash").textContent = message;
|
||||||
|
$("export-privkey-flash").style.visibility = "visible";
|
||||||
|
}
|
||||||
|
|
||||||
// Wipe every trace of the key and drop the address selection. Safe to call
|
// Wipe every trace of the key and drop the address selection. Safe to call
|
||||||
// when nothing was ever revealed, and safe to call twice.
|
// when nothing was ever revealed, and safe to call twice.
|
||||||
function clear() {
|
function clear() {
|
||||||
@@ -68,7 +71,8 @@ function clear() {
|
|||||||
$("export-privkey-password").value = "";
|
$("export-privkey-password").value = "";
|
||||||
$("export-privkey-result").classList.add("hidden");
|
$("export-privkey-result").classList.add("hidden");
|
||||||
$("export-privkey-password-section").classList.remove("hidden");
|
$("export-privkey-password-section").classList.remove("hidden");
|
||||||
hideError("export-privkey-password-error");
|
$("export-privkey-flash").textContent = "";
|
||||||
|
$("export-privkey-flash").style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(walletIdx, addrIdx) {
|
function show(walletIdx, addrIdx) {
|
||||||
@@ -108,19 +112,15 @@ function show(walletIdx, addrIdx) {
|
|||||||
async function reveal() {
|
async function reveal() {
|
||||||
const password = $("export-privkey-password").value;
|
const password = $("export-privkey-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError(
|
fail("Please enter your password.");
|
||||||
"export-privkey-password-error",
|
|
||||||
"Please enter your password.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (walletIndex === null) {
|
if (walletIndex === null) {
|
||||||
showError("export-privkey-password-error", "No address is selected.");
|
fail("No address is selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wallet = state.wallets[walletIndex];
|
const wallet = state.wallets[walletIndex];
|
||||||
|
|
||||||
hideError("export-privkey-password-error");
|
|
||||||
const btn = $("btn-export-privkey-confirm");
|
const btn = $("btn-export-privkey-confirm");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
@@ -135,28 +135,16 @@ async function reveal() {
|
|||||||
// decrypt ran, the wipe has already happened, so the key is not
|
// decrypt ran, the wipe has already happened, so the key is not
|
||||||
// even derived, let alone written.
|
// even derived, let alone written.
|
||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
let signer;
|
const signer = getSignerForAddress(wallet, addressIndex, secret);
|
||||||
try {
|
|
||||||
signer = getSignerForAddress(
|
|
||||||
wallet,
|
|
||||||
wallet.addresses[addressIndex],
|
|
||||||
secret,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// The password was right; it is the key that was refused.
|
|
||||||
showError("export-privkey-password-error", e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$("export-privkey-password").value = "";
|
$("export-privkey-password").value = "";
|
||||||
$("export-privkey-password-section").classList.add("hidden");
|
$("export-privkey-password-section").classList.add("hidden");
|
||||||
$("export-privkey-value").textContent = signer.privateKey;
|
$("export-privkey-value").textContent = signer.privateKey;
|
||||||
$("export-privkey-result").classList.remove("hidden");
|
$("export-privkey-result").classList.remove("hidden");
|
||||||
|
$("export-privkey-flash").textContent = "";
|
||||||
|
$("export-privkey-flash").style.visibility = "hidden";
|
||||||
} catch {
|
} catch {
|
||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
showError(
|
fail("That password is incorrect. Please try again.");
|
||||||
"export-privkey-password-error",
|
|
||||||
"That password is incorrect. Please try again.",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
|
|||||||
+33
-155
@@ -1,7 +1,4 @@
|
|||||||
// Helpers the views in this directory share: showing a view and going back,
|
// Shared DOM helpers used by all views.
|
||||||
// error and flash lines and the warning banners, formatting of amounts, fees,
|
|
||||||
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
|
|
||||||
// of the network a chain id names, explorer links, and click-to-copy.
|
|
||||||
//
|
//
|
||||||
// Escaping rule for every view in this directory, since they all build
|
// Escaping rule for every view in this directory, since they all build
|
||||||
// markup by concatenation: any VALUE interpolated into an innerHTML string
|
// markup by concatenation: any VALUE interpolated into an innerHTML string
|
||||||
@@ -21,19 +18,13 @@ const {
|
|||||||
truncateAmountNeverZero,
|
truncateAmountNeverZero,
|
||||||
isBelowOneMillionth,
|
isBelowOneMillionth,
|
||||||
} = require("../../shared/amountDisplay");
|
} = require("../../shared/amountDisplay");
|
||||||
const {
|
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||||
resolveTokenDecimals,
|
|
||||||
resolveTokenSymbol,
|
|
||||||
} = require("../../shared/approvalAmount");
|
|
||||||
const { getProvider } = require("../../shared/balances");
|
|
||||||
const { DEBUG } = require("../../shared/constants");
|
const { DEBUG } = require("../../shared/constants");
|
||||||
const { escapeHtml } = require("../../shared/html");
|
const { escapeHtml } = require("../../shared/html");
|
||||||
const { isDebug } = require("../../shared/log");
|
const { isDebug } = require("../../shared/log");
|
||||||
const { networkByChainId } = require("../../shared/networks");
|
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { displaySymbol } = require("../../shared/symbolDisplay");
|
const { displaySymbol } = require("../../shared/symbolDisplay");
|
||||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
|
||||||
const { markViewRendered } = require("../viewRouter");
|
const { markViewRendered } = require("../viewRouter");
|
||||||
|
|
||||||
// When views are added, removed, or transitions between them change,
|
// When views are added, removed, or transitions between them change,
|
||||||
@@ -60,7 +51,6 @@ const VIEWS = [
|
|||||||
"approve-site",
|
"approve-site",
|
||||||
"approve-tx",
|
"approve-tx",
|
||||||
"approve-sign",
|
"approve-sign",
|
||||||
"approve-network",
|
|
||||||
"export-privkey",
|
"export-privkey",
|
||||||
"show-phrase",
|
"show-phrase",
|
||||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||||
@@ -135,8 +125,7 @@ function showView(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create or update the debug/insecure warning banner.
|
// Create or update the debug/insecure warning banner.
|
||||||
// Called on every view switch, after the settings debug toggle changes, and
|
// Called on every view switch and after the settings debug toggle changes.
|
||||||
// after a network switch in Settings.
|
|
||||||
// The banner is shown when the compile-time DEBUG constant is true OR when
|
// The banner is shown when the compile-time DEBUG constant is true OR when
|
||||||
// the user has enabled runtime debug mode via the settings easter egg, OR
|
// the user has enabled runtime debug mode via the settings easter egg, OR
|
||||||
// when the active network is a testnet.
|
// when the active network is a testnet.
|
||||||
@@ -293,85 +282,34 @@ function unknownableAmount(balance) {
|
|||||||
|
|
||||||
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
|
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
|
||||||
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
|
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
|
||||||
// the Send screen, which sends on the active network, label a native amount
|
// the Send and confirmation screens, which send on the active network, label a
|
||||||
// with this; the confirmation screen, and a transaction already made or
|
// native amount with this; a transaction already made or requested is labelled
|
||||||
// requested, label it by the transaction's own chain id, through
|
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
|
||||||
// nativeCurrencyByChainId() in networks.js. The "ETH" that state.selectedToken
|
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
|
||||||
// and txInfo.token hold is the native token's id, not its label, and stays
|
// id, not its label, and stays "ETH" on every network.
|
||||||
// "ETH" on every network.
|
|
||||||
function nativeCurrency() {
|
function nativeCurrency() {
|
||||||
return currentNetwork().nativeCurrency;
|
return currentNetwork().nativeCurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the wallet knows of the tokens on the network with this chain id, or
|
// The symbol shown for a token line, resolved from the bundled list, the
|
||||||
// on the active network when no chain id is given, as the `sources` that
|
// tokens the user tracks, and the explorer's report — the same chain the
|
||||||
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
|
// amount line's scale comes from. Null when no source names one, so the token
|
||||||
// names a token on one network only, and every token balance held is the
|
// lines keep saying `Unknown token` for a token nothing knows.
|
||||||
// active network's, so another network gets the tokens tracked on it
|
function tokenLabel(address) {
|
||||||
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
|
return resolveTokenSymbol(address, {
|
||||||
//
|
trackedTokens: state.trackedTokens,
|
||||||
// The wallets' balances hold what Blockscout reported for each token. So the
|
wallets: state.wallets,
|
||||||
// wallets are returned only while "Use Blockscout" is on.
|
});
|
||||||
function tokenSourcesByChainId(chainId) {
|
|
||||||
const net = chainId ? networkByChainId(chainId) : currentNetwork();
|
|
||||||
if (net && net.id === state.networkId) {
|
|
||||||
return {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.useBlockscout ? state.wallets : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
|
|
||||||
wallets: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The line shown under a token while "Use Blockscout" is off. The token gets
|
|
||||||
// the line only when the bundled list and the tracked tokens lack its name or
|
|
||||||
// decimals. Otherwise this returns "". The token is a contract address on the
|
|
||||||
// network with `chainId`, or on the active network when none is given. "ETH"
|
|
||||||
// and null are no contract, and get "". A token that is not text also gets "":
|
|
||||||
// WaitTx and ErrorTx pass a token restored from storage unchecked.
|
|
||||||
function blockscoutOffTokenNoteHtml(token, chainId) {
|
|
||||||
if (typeof token !== "string") return "";
|
|
||||||
if (state.useBlockscout || !token || token === "ETH") return "";
|
|
||||||
const sources = tokenSourcesByChainId(chainId);
|
|
||||||
if (
|
|
||||||
resolveTokenSymbol(token, sources) !== null &&
|
|
||||||
resolveTokenDecimals(token, sources) !== null
|
|
||||||
) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
'<div class="text-xs text-muted">Blockscout\'s report of your balances' +
|
|
||||||
' is not used to name this token, because "Use Blockscout" is off in' +
|
|
||||||
" Settings.</div>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The symbol shown for a token line, from these sources in order:
|
|
||||||
//
|
|
||||||
// - the bundled list
|
|
||||||
// - the tokens the user tracks
|
|
||||||
// - the explorer's report, while "Use Blockscout" is on
|
|
||||||
//
|
|
||||||
// The network is the one with `chainId`, or the active network when none is
|
|
||||||
// given. The amount line's scale comes from the same sources. Null when no
|
|
||||||
// source names one, so the token lines keep saying `Unknown token` for a token
|
|
||||||
// nothing knows.
|
|
||||||
function tokenLabel(address, chainId) {
|
|
||||||
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A network fee in wei as the confirmation and approval screens both show it:
|
// A network fee in wei as the confirmation and approval screens both show it:
|
||||||
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
|
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
|
||||||
// native currency of the network the fee is paid on, then its USD value when
|
// native currency of the network the fee is paid on, then its USD value when
|
||||||
// the ETH price is known on the network with `chainId`, or on the active
|
// the ETH price is known. The USD value is of the exact fee, not of the
|
||||||
// network when no chain id is given. The USD value is of the exact fee, not of
|
// truncated figure.
|
||||||
// the truncated figure.
|
function formatFee(wei, symbol) {
|
||||||
function formatFee(wei, symbol, chainId) {
|
|
||||||
const eth = formatEther(wei);
|
const eth = formatEther(wei);
|
||||||
const ethPrice = getPrice("ETH", chainId);
|
const ethPrice = getPrice("ETH");
|
||||||
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
||||||
return (
|
return (
|
||||||
truncateAmountNeverZero(eth) +
|
truncateAmountNeverZero(eth) +
|
||||||
@@ -417,15 +355,6 @@ function balanceLine(symbol, amount, price, tokenId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracks `token` on the active network, as every add path on AddToken and
|
|
||||||
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
|
|
||||||
// until a balance refresh has read the token's balance at every address
|
|
||||||
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
|
|
||||||
// balance row for the token shows `quantity unknown` for it, not 0.0000.
|
|
||||||
function trackToken(token) {
|
|
||||||
state.trackedTokens.push({ ...token, balanceNotFetched: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||||
let html = balanceLine(
|
let html = balanceLine(
|
||||||
nativeCurrency(),
|
nativeCurrency(),
|
||||||
@@ -458,7 +387,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
if (seen.has(t.address.toLowerCase())) continue;
|
if (seen.has(t.address.toLowerCase())) continue;
|
||||||
html += balanceLine(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
t.balanceNotFetched ? null : 0,
|
0,
|
||||||
getPrice(t.symbol),
|
getPrice(t.symbol),
|
||||||
t.address.toLowerCase(),
|
t.address.toLowerCase(),
|
||||||
);
|
);
|
||||||
@@ -488,16 +417,6 @@ function addressHoldsFunds(addr) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The name of a wallet's key material, which is also its only backup: the
|
|
||||||
// recovery phrase of an HD wallet, the private key of a key wallet, the
|
|
||||||
// extended private key of an xprv wallet. DeleteWallet,
|
|
||||||
// DeleteWalletLostPassword and DeleteAddress name the backup with it.
|
|
||||||
function keyMaterialName(wallet) {
|
|
||||||
if (walletHasRecoveryPhrase(wallet)) return "recovery phrase";
|
|
||||||
if (wallet.type === "key") return "private key";
|
|
||||||
return "extended private key";
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fewest characters of an address any caller may ask to display. The
|
// The fewest characters of an address any caller may ask to display. The
|
||||||
// 10-character cap inside truncateMiddle() is the other half of the same
|
// 10-character cap inside truncateMiddle() is the other half of the same
|
||||||
// guarantee; this is the half that used to be spelled out at each call
|
// guarantee; this is the half that used to be spelled out at each call
|
||||||
@@ -580,12 +499,6 @@ function addressTitle(address, wallets) {
|
|||||||
const CONTRACT_CREATION_TEXT =
|
const CONTRACT_CREATION_TEXT =
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
"This transaction creates a new contract. It has no recipient.";
|
||||||
|
|
||||||
// The line shown in place of the transaction history on Home, AddressDetail and
|
|
||||||
// AddressToken while "Use Blockscout" is off. That history comes from
|
|
||||||
// Blockscout alone.
|
|
||||||
const HISTORY_OFF_TEXT =
|
|
||||||
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
|
|
||||||
|
|
||||||
// The last two lines of a transaction history row: the counterparty's colour
|
// The last two lines of a transaction history row: the counterparty's colour
|
||||||
// dot and name beside the amount, then its full address. A contract creation
|
// dot and name beside the amount, then its full address. A contract creation
|
||||||
// the user sent has no counterparty (its `to` is ""), so its row has the
|
// the user sent has no counterparty (its `to` is ""), so its row has the
|
||||||
@@ -678,44 +591,18 @@ const EXT_ICON =
|
|||||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||||
`</svg></span>`;
|
`</svg></span>`;
|
||||||
|
|
||||||
// The RPC and Blockscout URLs Settings has for the network with this chain id,
|
|
||||||
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail
|
|
||||||
// use the network their transaction's chain id names, and the popup can reopen
|
|
||||||
// onto them after Settings switched the network. The active network's are
|
|
||||||
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it,
|
|
||||||
// or its defaults (see applyChainSwitchFields()).
|
|
||||||
function endpointsByChainId(chainId) {
|
|
||||||
const net = networkByChainId(chainId);
|
|
||||||
if (net.id === state.networkId) {
|
|
||||||
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
|
|
||||||
}
|
|
||||||
const remembered = state.networkEndpoints[net.id] || {};
|
|
||||||
return {
|
|
||||||
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
|
|
||||||
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The node of the network a transaction was reviewed for.
|
|
||||||
function reviewedNetworkProvider(txInfo) {
|
|
||||||
const net = networkByChainId(txInfo.chainId);
|
|
||||||
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block-explorer URLs. The origin is a per-network constant from
|
// Block-explorer URLs. The origin is a per-network constant from
|
||||||
// src/shared/networks.js, the explorer of the network with `chainId`, or of
|
// src/shared/networks.js; only the path segment is data, and it comes out
|
||||||
// the active network when no chain id is given; only the path segment is
|
// of explorer JSON (a transaction's from/to, a token's address_hash), which
|
||||||
// data, and it comes out of explorer JSON (a transaction's from/to, a token's
|
// nothing upstream validates as hex. percent-encoding it keeps a segment
|
||||||
// address_hash), which nothing upstream validates as hex. percent-encoding it
|
// that contains a slash, a query or a fragment from re-pointing the link
|
||||||
// keeps a segment that contains a slash, a query or a fragment from
|
// somewhere else in the explorer.
|
||||||
// re-pointing the link somewhere else in the explorer.
|
function explorerUrl(kind, value) {
|
||||||
function explorerUrl(kind, value, chainId) {
|
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
|
||||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
|
||||||
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function etherscanAddressUrl(address, chainId) {
|
function etherscanAddressUrl(address) {
|
||||||
return explorerUrl("address", address, chainId);
|
return explorerUrl("address", address);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The URL still has to be escaped on the way into href="...": encoding
|
// The URL still has to be escaped on the way into href="...": encoding
|
||||||
@@ -781,18 +668,16 @@ function attachCopyHandlers(container) {
|
|||||||
// with them, and the "at least 32 characters" guarantee has
|
// with them, and the "at least 32 characters" guarantee has
|
||||||
// to survive having no current callers to be a guarantee.
|
// to survive having no current callers to be a guarantee.
|
||||||
// noLink — if true, omit etherscan link
|
// noLink — if true, omit etherscan link
|
||||||
// chainId — the network whose explorer the link opens; the active
|
|
||||||
// network's when absent
|
|
||||||
//
|
//
|
||||||
// After inserting the returned HTML into the DOM, call
|
// After inserting the returned HTML into the DOM, call
|
||||||
// attachCopyHandlers() on the parent to wire up click-to-copy.
|
// attachCopyHandlers() on the parent to wire up click-to-copy.
|
||||||
function renderAddressHtml(address, opts) {
|
function renderAddressHtml(address, opts) {
|
||||||
const { title, ensName, maxLen, noLink, chainId } = opts || {};
|
const { title, ensName, maxLen, noLink } = opts || {};
|
||||||
const dot = addressDotHtml(address);
|
const dot = addressDotHtml(address);
|
||||||
const displayAddr = maxLen
|
const displayAddr = maxLen
|
||||||
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
|
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
|
||||||
: address;
|
: address;
|
||||||
const link = etherscanAddressUrl(address, chainId);
|
const link = etherscanAddressUrl(address);
|
||||||
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
||||||
|
|
||||||
let html = "";
|
let html = "";
|
||||||
@@ -838,13 +723,9 @@ module.exports = {
|
|||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
trackToken,
|
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
keyMaterialName,
|
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
nativeCurrency,
|
nativeCurrency,
|
||||||
tokenSourcesByChainId,
|
|
||||||
blockscoutOffTokenNoteHtml,
|
|
||||||
tokenLabel,
|
tokenLabel,
|
||||||
formatFee,
|
formatFee,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
@@ -853,7 +734,6 @@ module.exports = {
|
|||||||
displaySymbol,
|
displaySymbol,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
CONTRACT_CREATION_TEXT,
|
||||||
HISTORY_OFF_TEXT,
|
|
||||||
txCounterpartyHtml,
|
txCounterpartyHtml,
|
||||||
formatAddressHtml,
|
formatAddressHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
@@ -861,8 +741,6 @@ module.exports = {
|
|||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
explorerUrl,
|
explorerUrl,
|
||||||
endpointsByChainId,
|
|
||||||
reviewedNetworkProvider,
|
|
||||||
EXT_ICON,
|
EXT_ICON,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
isoDate,
|
isoDate,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const {
|
|||||||
timeAgo,
|
timeAgo,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
txCounterpartyHtml,
|
txCounterpartyHtml,
|
||||||
HISTORY_OFF_TEXT,
|
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -101,10 +100,6 @@ let homeTxs = [];
|
|||||||
function renderHomeTxList(ctx) {
|
function renderHomeTxList(ctx) {
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
if (!state.useBlockscout) {
|
|
||||||
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (homeTxs.length === 0) {
|
if (homeTxs.length === 0) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||||
@@ -305,8 +300,6 @@ function walletListHtml() {
|
|||||||
|
|
||||||
function render(ctx) {
|
function render(ctx) {
|
||||||
const container = $("wallet-list");
|
const container = $("wallet-list");
|
||||||
// Only Blockscout reports the tokens the user has not tracked.
|
|
||||||
$("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
|
||||||
if (state.wallets.length === 0) {
|
if (state.wallets.length === 0) {
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
||||||
@@ -361,7 +354,6 @@ function render(ctx) {
|
|||||||
);
|
);
|
||||||
wallet.addresses.push({
|
wallet.addresses.push({
|
||||||
address: newAddr,
|
address: newAddr,
|
||||||
index: wallet.nextIndex,
|
|
||||||
balance: "0.0000",
|
balance: "0.0000",
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
});
|
});
|
||||||
@@ -454,7 +446,7 @@ function init(ctx) {
|
|||||||
}
|
}
|
||||||
$("send-to").value = "";
|
$("send-to").value = "";
|
||||||
$("send-amount").value = "";
|
$("send-amount").value = "";
|
||||||
$("send-token-dropdown").classList.remove("hidden");
|
$("send-token").classList.remove("hidden");
|
||||||
$("send-token-static").classList.add("hidden");
|
$("send-token-static").classList.add("hidden");
|
||||||
renderSendTokenSelect(addr);
|
renderSendTokenSelect(addr);
|
||||||
updateSendBalance();
|
updateSendBalance();
|
||||||
|
|||||||
+39
-46
@@ -10,7 +10,6 @@ const {
|
|||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
tokenSourcesByChainId,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress } = require("../../shared/state");
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -25,7 +24,6 @@ const {
|
|||||||
} = require("../../shared/amountDisplay");
|
} = require("../../shared/amountDisplay");
|
||||||
const {
|
const {
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
isValidAmount,
|
|
||||||
maxEthAmount,
|
maxEthAmount,
|
||||||
maxTokenAmount,
|
maxTokenAmount,
|
||||||
} = require("../../shared/txValidation");
|
} = require("../../shared/txValidation");
|
||||||
@@ -142,8 +140,6 @@ function updateToValidation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSendTokenSelect(addr) {
|
function renderSendTokenSelect(addr) {
|
||||||
// Only Blockscout reports the tokens the user has not tracked.
|
|
||||||
$("send-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
|
||||||
const sel = $("send-token");
|
const sel = $("send-token");
|
||||||
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
|
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
|
||||||
const fraudSet = new Set(
|
const fraudSet = new Set(
|
||||||
@@ -167,9 +163,8 @@ function renderSendTokenSelect(addr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The token balance the Send screen states, and the scale it hands the
|
// The token balance and scale the Send screen states and hands the
|
||||||
// confirmation screen, which reads the balance itself from the network it is
|
// confirmation screen, so the two screens describe the holding the same way.
|
||||||
// drawn for.
|
|
||||||
//
|
//
|
||||||
// The scale is resolved the same way balances.js resolved the scale it
|
// The scale is resolved the same way balances.js resolved the scale it
|
||||||
// DISPLAYED this token's balance at: bundled list, then the user's tracked
|
// DISPLAYED this token's balance at: bundled list, then the user's tracked
|
||||||
@@ -188,22 +183,25 @@ function renderSendTokenSelect(addr) {
|
|||||||
// so the transfer is encoded with the number the user read rather than with
|
// so the transfer is encoded with the number the user read rather than with
|
||||||
// whatever the contract answers at signing time (see
|
// whatever the contract answers at signing time (see
|
||||||
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
||||||
// fetch time and cannot consult a state it is in the middle of replacing. As on
|
// fetch time and cannot consult a state it is in the middle of replacing.
|
||||||
// the approval screens, tokenSourcesByChainId() gives the wallets only while
|
|
||||||
// "Use Blockscout" is on.
|
|
||||||
//
|
//
|
||||||
// The two resolutions can therefore differ, and where they do, the stored
|
// The two resolutions can therefore differ, and where they do, the stored
|
||||||
// `balance` is a quantity computed at a scale this screen has just declined to
|
// `balance` is a quantity computed at a scale this screen has just declined to
|
||||||
// stand behind, so it is withdrawn: unknown scale means unknown balance. It is
|
// stand behind. Stating it would leave validateTransfer() checking the amount
|
||||||
// null rather than "0": this screen states it as unknown, as the confirmation
|
// against a number the wallet does not vouch for, so it is withdrawn: unknown
|
||||||
// screen does, and Max fills nothing in for it. Only a stored quantity is
|
// scale means unknown balance. It is null rather than "0": both screens state
|
||||||
// withdrawn: the "0" for a token that has no row at all is an absence of
|
// an unknown balance as unknown, and validateTransfer() treats it as no balance
|
||||||
// holdings, which is true at every scale.
|
// to spend from, which is the fail-closed side of an amount nobody can check.
|
||||||
|
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
|
||||||
|
// all is an absence of holdings, which is true at every scale.
|
||||||
function tokenBalanceAndDecimals(addr, token) {
|
function tokenBalanceAndDecimals(addr, token) {
|
||||||
const tb = (addr.tokenBalances || []).find(
|
const tb = (addr.tokenBalances || []).find(
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
);
|
);
|
||||||
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
|
const tokenDecimals = resolveTokenDecimals(token, {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
||||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||||
@@ -257,18 +255,13 @@ async function fillMaxAmount() {
|
|||||||
const token = state.selectedToken || $("send-token").value;
|
const token = state.selectedToken || $("send-token").value;
|
||||||
|
|
||||||
if (token !== "ETH") {
|
if (token !== "ETH") {
|
||||||
const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
|
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||||
addr,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
if (bal == null) {
|
if (bal == null) {
|
||||||
showFlash("This token's balance is unknown.");
|
showFlash("This token's balance is unknown.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amount = maxTokenAmount(bal);
|
const amount = maxTokenAmount(bal);
|
||||||
// The balance is stored at the token's own decimal places and cut to
|
if (!(parseFloat(amount) > 0)) {
|
||||||
// 18 above, so the only balance this refuses is zero.
|
|
||||||
if (!isValidAmount(amount, tokenDecimals)) {
|
|
||||||
showFlash("This token's balance is zero.");
|
showFlash("This token's balance is zero.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -374,24 +367,7 @@ function init(_ctx) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||||
const token = state.selectedToken || $("send-token").value;
|
|
||||||
const addr = currentAddress();
|
|
||||||
|
|
||||||
let tokenSymbol = null;
|
|
||||||
let tokenDecimals = null;
|
|
||||||
if (token !== "ETH") {
|
|
||||||
tokenSymbol = resolveSymbol(
|
|
||||||
token,
|
|
||||||
addr.tokenBalances,
|
|
||||||
state.trackedTokens,
|
|
||||||
);
|
|
||||||
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The confirmation screen's own amount check, so it is never shown
|
|
||||||
// an amount it would refuse.
|
|
||||||
if (!isValidAmount(amount, tokenDecimals)) {
|
|
||||||
showFlash("Please enter a valid amount.");
|
showFlash("Please enter a valid amount.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -403,11 +379,8 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const resolved = await provider.resolveName(to);
|
const resolved = await provider.resolveName(to);
|
||||||
// getProvider() does not look up a name resolved off-chain,
|
|
||||||
// so such a name comes back with no address too. The message
|
|
||||||
// covers both, in the one line the flash area shows.
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
showFlash("That ENS name has no on-chain address.");
|
showFlash("That ENS name has no address.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedTo = resolved;
|
resolvedTo = resolved;
|
||||||
@@ -418,20 +391,40 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = state.selectedToken || $("send-token").value;
|
||||||
|
const addr = currentAddress();
|
||||||
|
|
||||||
|
let tokenSymbol = null;
|
||||||
|
let tokenBalance = null;
|
||||||
|
let tokenDecimals = null;
|
||||||
|
if (token !== "ETH") {
|
||||||
|
tokenSymbol = resolveSymbol(
|
||||||
|
token,
|
||||||
|
addr.tokenBalances,
|
||||||
|
state.trackedTokens,
|
||||||
|
);
|
||||||
|
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
||||||
|
addr,
|
||||||
|
token,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
from: addr.address,
|
from: addr.address,
|
||||||
to: resolvedTo,
|
to: resolvedTo,
|
||||||
ensName: ensName,
|
ensName: ensName,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
token: token,
|
token: token,
|
||||||
|
balance: addr.balance,
|
||||||
tokenSymbol: tokenSymbol,
|
tokenSymbol: tokenSymbol,
|
||||||
|
tokenBalance: tokenBalance,
|
||||||
tokenDecimals: tokenDecimals,
|
tokenDecimals: tokenDecimals,
|
||||||
max: amountIsMax,
|
max: amountIsMax,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-send-back").addEventListener("click", () => {
|
$("btn-send-back").addEventListener("click", () => {
|
||||||
$("send-token-dropdown").classList.remove("hidden");
|
$("send-token").classList.remove("hidden");
|
||||||
$("send-token-static").classList.add("hidden");
|
$("send-token-static").classList.add("hidden");
|
||||||
goBack();
|
goBack();
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-139
@@ -16,9 +16,6 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
const { restorableStack } = require("../../shared/persistedState");
|
|
||||||
const { dropUntrackedTokens } = require("../../shared/balances");
|
|
||||||
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
|
|
||||||
const {
|
const {
|
||||||
log,
|
log,
|
||||||
debugFetch,
|
debugFetch,
|
||||||
@@ -39,8 +36,6 @@ const {
|
|||||||
|
|
||||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
const { notify, sendMessage } = require("../../shared/browserApi");
|
||||||
|
|
||||||
let ctx;
|
|
||||||
|
|
||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
|
|
||||||
@@ -81,13 +76,11 @@ function forgetOrigin(siteMap, origin) {
|
|||||||
|
|
||||||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
||||||
// no longer allowed under any address, and the background ends its
|
// no longer allowed under any address, and the background ends its
|
||||||
// connections approved without "Remember" and tells its open tabs. The
|
// connections approved without "Remember" and tells its open tabs.
|
||||||
// background answers once those have ended, so the lists drawn after it no
|
|
||||||
// longer show the site.
|
|
||||||
async function removeAllowedSite(origin) {
|
async function removeAllowedSite(origin) {
|
||||||
forgetOrigin(state.allowedSites, origin);
|
forgetOrigin(state.allowedSites, origin);
|
||||||
await saveState();
|
await saveState();
|
||||||
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
||||||
await renderSiteLists();
|
await renderSiteLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +195,6 @@ function show() {
|
|||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
$("settings-network").value = state.networkId;
|
$("settings-network").value = state.networkId;
|
||||||
// Set on every show, not only at init: Welcome can change these two.
|
|
||||||
$("settings-use-blockscout").checked = state.useBlockscout;
|
|
||||||
$("settings-check-recipients-on-etherscan").checked =
|
|
||||||
state.checkRecipientsOnEtherscan;
|
|
||||||
renderTrackedTokens();
|
renderTrackedTokens();
|
||||||
renderSiteLists();
|
renderSiteLists();
|
||||||
renderWalletListSettings();
|
renderWalletListSettings();
|
||||||
@@ -241,8 +230,8 @@ async function renderSiteLists() {
|
|||||||
Object.values(state.deniedSites).flat(),
|
Object.values(state.deniedSites).flat(),
|
||||||
removeDeniedSite,
|
removeDeniedSite,
|
||||||
);
|
);
|
||||||
// Sites allowed without "Remember" are held only in session storage, which
|
// Sites allowed without "Remember" are held only by the background, in
|
||||||
// the background keeps, so it is asked for them.
|
// memory, so it is asked for them.
|
||||||
renderSiteList(
|
renderSiteList(
|
||||||
"settings-connected-sites",
|
"settings-connected-sites",
|
||||||
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||||||
@@ -250,77 +239,7 @@ async function renderSiteLists() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A network switch, made by the user here or saved by the background once the
|
function init(ctx) {
|
||||||
// user approved a site's request (src/popup/index.js), leaves every screen on
|
|
||||||
// the page showing the previous network. Two functions leave the page as
|
|
||||||
// README's Screen Map describes:
|
|
||||||
//
|
|
||||||
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
|
|
||||||
// a popup closed straight after
|
|
||||||
// - showNewSettings(), once `state` holds the new network
|
|
||||||
//
|
|
||||||
// The stack is cut at the first screen that cannot be rendered from saved
|
|
||||||
// state, such as Send or AddToken, and at AddressToken unless it shows the
|
|
||||||
// native token: any other token there was picked on the previous network, so
|
|
||||||
// it is no longer selected either. Without a wallet the stack starts at
|
|
||||||
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
|
|
||||||
// A change to "Use Blockscout" cuts the stack the same way (setUseBlockscout()
|
|
||||||
// below).
|
|
||||||
function cutViewStack() {
|
|
||||||
if (!state.hasWallet) return;
|
|
||||||
if (state.selectedToken !== "ETH") {
|
|
||||||
const tokenView = state.viewStack.indexOf("address-token");
|
|
||||||
if (tokenView !== -1) {
|
|
||||||
state.viewStack = state.viewStack.slice(0, tokenView);
|
|
||||||
}
|
|
||||||
state.selectedToken = null;
|
|
||||||
}
|
|
||||||
state.viewStack = restorableStack(state.viewStack, state.currentView);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets "Use Blockscout" to `on` on this page. It is called inside a save,
|
|
||||||
// holding the lock, for the user's change here or another page's
|
|
||||||
// (followStoredSettings() in src/popup/index.js).
|
|
||||||
//
|
|
||||||
// Send's token dropdown was filled under the old setting, and Back cannot draw
|
|
||||||
// Send again. So the stack is cut, as for a network switch. Turning the setting
|
|
||||||
// off also drops every token the user has not tracked.
|
|
||||||
function setUseBlockscout(on) {
|
|
||||||
state.useBlockscout = on;
|
|
||||||
cutViewStack();
|
|
||||||
if (!on) dropUntrackedTokens(state.wallets, state.trackedTokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws the page again after a network switch, or after another page changed
|
|
||||||
// "Use Blockscout" or "Check recipients on Etherscan". Every screen counts as
|
|
||||||
// not yet rendered, so "Back" renders again any screen that a reopened popup
|
|
||||||
// can render. Settings on top is brought up to date in place. Any other screen
|
|
||||||
// is drawn again as a reopened popup draws it, which is Home for one that
|
|
||||||
// cannot be rendered from saved state. Without a wallet any other screen is
|
|
||||||
// Welcome or AddWallet. Back only unhides Welcome, so its two checkboxes are
|
|
||||||
// set here.
|
|
||||||
function showNewSettings() {
|
|
||||||
resetRenderedViews();
|
|
||||||
$("welcome-use-blockscout").checked = state.useBlockscout;
|
|
||||||
$("welcome-check-recipients-on-etherscan").checked =
|
|
||||||
state.checkRecipientsOnEtherscan;
|
|
||||||
if (state.currentView === "settings") {
|
|
||||||
markViewRendered("settings");
|
|
||||||
$("settings-network").value = state.networkId;
|
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
|
||||||
$("settings-use-blockscout").checked = state.useBlockscout;
|
|
||||||
$("settings-check-recipients-on-etherscan").checked =
|
|
||||||
state.checkRecipientsOnEtherscan;
|
|
||||||
renderTrackedTokens();
|
|
||||||
} else if (state.hasWallet) {
|
|
||||||
ctx.restoreView();
|
|
||||||
}
|
|
||||||
updateDebugBanner(state.currentView);
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(_ctx) {
|
|
||||||
ctx = _ctx;
|
|
||||||
deleteWallet.init(ctx);
|
deleteWallet.init(ctx);
|
||||||
showPhrase.init();
|
showPhrase.init();
|
||||||
|
|
||||||
@@ -394,49 +313,12 @@ function init(_ctx) {
|
|||||||
showFlash("Saved.");
|
showFlash("Saved.");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each change is made inside the save, holding the lock. So
|
|
||||||
// followStoredSettings() in src/popup/index.js never mistakes this page's
|
|
||||||
// unsaved change for one that another page saved.
|
|
||||||
//
|
|
||||||
// The screens under Settings were drawn under the old setting. So each
|
|
||||||
// change makes every screen but Settings count as not yet drawn, as a
|
|
||||||
// network switch does. Back then draws again any screen that a reopened
|
|
||||||
// popup can draw. Back only unhides Welcome, so its boxes are set here too.
|
|
||||||
$("settings-use-blockscout").addEventListener("change", async () => {
|
|
||||||
const on = $("settings-use-blockscout").checked;
|
|
||||||
await saveState(() => setUseBlockscout(on));
|
|
||||||
$("welcome-use-blockscout").checked = on;
|
|
||||||
resetRenderedViews();
|
|
||||||
markViewRendered("settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
$("settings-check-recipients-on-etherscan").addEventListener(
|
|
||||||
"change",
|
|
||||||
async () => {
|
|
||||||
const on = $("settings-check-recipients-on-etherscan").checked;
|
|
||||||
await saveState(() => {
|
|
||||||
state.checkRecipientsOnEtherscan = on;
|
|
||||||
});
|
|
||||||
$("welcome-check-recipients-on-etherscan").checked = on;
|
|
||||||
resetRenderedViews();
|
|
||||||
markViewRendered("settings");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const networkSelect = $("settings-network");
|
const networkSelect = $("settings-network");
|
||||||
networkSelect.addEventListener("change", async () => {
|
networkSelect.addEventListener("change", async () => {
|
||||||
// Whether `state` already has the network picked is asked only once
|
|
||||||
// the switch holds the lock: while an earlier pick waits for it,
|
|
||||||
// `state` still has the network before that pick, and a pick back to
|
|
||||||
// that network must still be made.
|
|
||||||
const newId = networkSelect.value;
|
const newId = networkSelect.value;
|
||||||
cutViewStack();
|
|
||||||
const net = await onChainSwitch(newId);
|
const net = await onChainSwitch(newId);
|
||||||
if (!net) return;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
// Open pages are told by the background, as after a site's approved
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
// switch request.
|
|
||||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
|
||||||
showNewSettings();
|
|
||||||
showFlash("Switched to " + net.name + ".");
|
showFlash("Switched to " + net.name + ".");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -499,12 +381,6 @@ function init(_ctx) {
|
|||||||
await saveState();
|
await saveState();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("settings-allow-weak-passwords").checked = state.allowWeakPasswords;
|
|
||||||
$("settings-allow-weak-passwords").addEventListener("change", async () => {
|
|
||||||
state.allowWeakPasswords = $("settings-allow-weak-passwords").checked;
|
|
||||||
await saveState();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
||||||
|
|
||||||
$("btn-settings-add-token").addEventListener(
|
$("btn-settings-add-token").addEventListener(
|
||||||
@@ -577,11 +453,4 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = { init, show, renderSiteLists };
|
||||||
init,
|
|
||||||
show,
|
|
||||||
renderSiteLists,
|
|
||||||
cutViewStack,
|
|
||||||
setUseBlockscout,
|
|
||||||
showNewSettings,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
const {
|
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||||
$,
|
|
||||||
showView,
|
|
||||||
showFlash,
|
|
||||||
escapeHtml,
|
|
||||||
goBack,
|
|
||||||
trackToken,
|
|
||||||
} = require("./helpers");
|
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -50,7 +43,7 @@ function renderTop10() {
|
|||||||
decimals: parseInt(btn.dataset.decimals, 10),
|
decimals: parseInt(btn.dataset.decimals, 10),
|
||||||
name: btn.dataset.name || btn.dataset.symbol,
|
name: btn.dataset.name || btn.dataset.symbol,
|
||||||
};
|
};
|
||||||
trackToken(token);
|
state.trackedTokens.push(token);
|
||||||
await saveState();
|
await saveState();
|
||||||
showFlash("Added " + token.symbol);
|
showFlash("Added " + token.symbol);
|
||||||
renderTop10();
|
renderTop10();
|
||||||
@@ -82,12 +75,6 @@ function show() {
|
|||||||
$("settings-addtoken-address").value = "";
|
$("settings-addtoken-address").value = "";
|
||||||
$("settings-addtoken-info").textContent = "";
|
$("settings-addtoken-info").textContent = "";
|
||||||
$("settings-addtoken-info").style.visibility = "hidden";
|
$("settings-addtoken-info").style.visibility = "hidden";
|
||||||
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
|
||||||
// on any other network.
|
|
||||||
$("settings-addtoken-common").classList.toggle(
|
|
||||||
"hidden",
|
|
||||||
state.networkId !== "mainnet",
|
|
||||||
);
|
|
||||||
renderTop10();
|
renderTop10();
|
||||||
renderDropdown();
|
renderDropdown();
|
||||||
showView("settings-addtoken");
|
showView("settings-addtoken");
|
||||||
@@ -117,7 +104,7 @@ function init(_ctx) {
|
|||||||
decimals: parseInt(opt.dataset.decimals, 10),
|
decimals: parseInt(opt.dataset.decimals, 10),
|
||||||
name: opt.dataset.name || opt.dataset.symbol,
|
name: opt.dataset.name || opt.dataset.symbol,
|
||||||
};
|
};
|
||||||
trackToken(token);
|
state.trackedTokens.push(token);
|
||||||
await saveState();
|
await saveState();
|
||||||
showFlash("Added " + token.symbol);
|
showFlash("Added " + token.symbol);
|
||||||
renderTop10();
|
renderTop10();
|
||||||
@@ -143,9 +130,6 @@ function init(_ctx) {
|
|||||||
infoEl.textContent = "Looking up token...";
|
infoEl.textContent = "Looking up token...";
|
||||||
infoEl.style.visibility = "visible";
|
infoEl.style.visibility = "visible";
|
||||||
log.debugf("Looking up token contract", addr);
|
log.debugf("Looking up token contract", addr);
|
||||||
// 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;
|
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
addr,
|
addr,
|
||||||
@@ -153,17 +137,8 @@ function init(_ctx) {
|
|||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
// A contract address names a token on one network only, so an
|
|
||||||
// answer after a switch is not added to the new network's list.
|
|
||||||
if (currentNetwork().chainId !== chainId) {
|
|
||||||
showFlash(
|
|
||||||
"The active network changed before the lookup finished," +
|
|
||||||
" so the token was not added.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.infof("Adding token", info.symbol, addr);
|
log.infof("Adding token", info.symbol, addr);
|
||||||
trackToken({
|
state.trackedTokens.push({
|
||||||
address: addr,
|
address: addr,
|
||||||
symbol: info.symbol,
|
symbol: info.symbol,
|
||||||
decimals: info.decimals,
|
decimals: info.decimals,
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
showError,
|
|
||||||
hideError,
|
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -54,6 +52,11 @@ function isCurrentReveal(generation) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
$("show-phrase-flash").textContent = message;
|
||||||
|
$("show-phrase-flash").style.visibility = "visible";
|
||||||
|
}
|
||||||
|
|
||||||
// Wipe every trace of the phrase and drop the wallet selection. Safe to
|
// Wipe every trace of the phrase and drop the wallet selection. Safe to
|
||||||
// call when nothing was ever revealed, and safe to call twice.
|
// call when nothing was ever revealed, and safe to call twice.
|
||||||
function clear() {
|
function clear() {
|
||||||
@@ -63,7 +66,8 @@ function clear() {
|
|||||||
$("show-phrase-password").value = "";
|
$("show-phrase-password").value = "";
|
||||||
$("show-phrase-result").classList.add("hidden");
|
$("show-phrase-result").classList.add("hidden");
|
||||||
$("show-phrase-password-section").classList.remove("hidden");
|
$("show-phrase-password-section").classList.remove("hidden");
|
||||||
hideError("show-phrase-password-error");
|
$("show-phrase-flash").textContent = "";
|
||||||
|
$("show-phrase-flash").style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(walletIdx) {
|
function show(walletIdx) {
|
||||||
@@ -86,23 +90,19 @@ function show(walletIdx) {
|
|||||||
async function reveal() {
|
async function reveal() {
|
||||||
const password = $("show-phrase-password").value;
|
const password = $("show-phrase-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError("show-phrase-password-error", "Please enter your password.");
|
fail("Please enter your password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (walletIndex === null) {
|
if (walletIndex === null) {
|
||||||
showError("show-phrase-password-error", "No wallet is selected.");
|
fail("No wallet is selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wallet = state.wallets[walletIndex];
|
const wallet = state.wallets[walletIndex];
|
||||||
if (!walletHasRecoveryPhrase(wallet)) {
|
if (!walletHasRecoveryPhrase(wallet)) {
|
||||||
showError(
|
fail("This wallet does not have a recovery phrase.");
|
||||||
"show-phrase-password-error",
|
|
||||||
"This wallet does not have a recovery phrase.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hideError("show-phrase-password-error");
|
|
||||||
const btn = $("btn-show-phrase-reveal");
|
const btn = $("btn-show-phrase-reveal");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
@@ -120,14 +120,13 @@ async function reveal() {
|
|||||||
$("show-phrase-password-section").classList.add("hidden");
|
$("show-phrase-password-section").classList.add("hidden");
|
||||||
$("show-phrase-value").textContent = phrase;
|
$("show-phrase-value").textContent = phrase;
|
||||||
$("show-phrase-result").classList.remove("hidden");
|
$("show-phrase-result").classList.remove("hidden");
|
||||||
|
$("show-phrase-flash").textContent = "";
|
||||||
|
$("show-phrase-flash").style.visibility = "hidden";
|
||||||
} catch {
|
} catch {
|
||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
// Deliberately not the caught error: the message is fixed so that
|
// Deliberately not the caught error: the message is fixed so that
|
||||||
// nothing derived from the ciphertext or the attempt can surface.
|
// nothing derived from the ciphertext or the attempt can surface.
|
||||||
showError(
|
fail("That password is incorrect. Please try again.");
|
||||||
"show-phrase-password-error",
|
|
||||||
"That password is incorrect. Please try again.",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
|
|||||||
@@ -24,11 +24,7 @@
|
|||||||
// than written again locally so that tests/popupElementIds.test.js sees these
|
// than written again locally so that tests/popupElementIds.test.js sees these
|
||||||
// lookups and holds every id below against the markup.
|
// lookups and holds every id below against the markup.
|
||||||
const { $, VIEWS } = require("./helpers");
|
const { $, VIEWS } = require("./helpers");
|
||||||
const {
|
const { storageGet, storageRemove } = require("../../shared/browserApi");
|
||||||
storageGet,
|
|
||||||
storageRemove,
|
|
||||||
withLock,
|
|
||||||
} = require("../../shared/browserApi");
|
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
// Typed in full before anything is erased, in the same spirit as the wallet
|
// Typed in full before anything is erased, in the same spirit as the wallet
|
||||||
@@ -141,9 +137,7 @@ async function resetProfile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Under the lock every write of the stored record takes, so the erase
|
await storageRemove("autistmask");
|
||||||
// cannot land between another write's read and its write and be undone.
|
|
||||||
await withLock("autistmask", () => storageRemove("autistmask"));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("state recovery: reset failed:", e);
|
log.errorf("state recovery: reset failed:", e);
|
||||||
setFlash("The saved data could not be erased. Nothing was changed.");
|
setFlash("The saved data could not be erased. Nothing was changed.");
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const {
|
|||||||
copyableHtml,
|
copyableHtml,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
explorerUrl,
|
explorerUrl,
|
||||||
endpointsByChainId,
|
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
goBack,
|
goBack,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
@@ -26,7 +25,6 @@ const { state } = require("../../shared/state");
|
|||||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||||
const { formatEther, formatUnits } = require("ethers");
|
const { formatEther, formatUnits } = require("ethers");
|
||||||
const { log, debugFetch } = require("../../shared/log");
|
const { log, debugFetch } = require("../../shared/log");
|
||||||
const { storedSettingIsOn } = require("../../shared/storedSettings");
|
|
||||||
const { decodeCalldata } = require("./approval");
|
const { decodeCalldata } = require("./approval");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,16 +48,16 @@ function getTransactionType(tx) {
|
|||||||
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
||||||
}
|
}
|
||||||
|
|
||||||
function txAddressHtml(address, ensName, title, chainId) {
|
function txAddressHtml(address, ensName, title) {
|
||||||
const blockie = blockieHtml(address);
|
const blockie = blockieHtml(address);
|
||||||
return (
|
return (
|
||||||
`<div class="mb-1">${blockie}</div>` +
|
`<div class="mb-1">${blockie}</div>` +
|
||||||
renderAddressHtml(address, { title, ensName, chainId })
|
renderAddressHtml(address, { title, ensName })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function txHashHtml(hash, chainId) {
|
function txHashHtml(hash) {
|
||||||
const link = explorerUrl("tx", hash, chainId);
|
const link = explorerUrl("tx", hash);
|
||||||
const extLink = etherscanLinkHtml(link);
|
const extLink = etherscanLinkHtml(link);
|
||||||
return copyableHtml(hash, "break-all") + extLink;
|
return copyableHtml(hash, "break-all") + extLink;
|
||||||
}
|
}
|
||||||
@@ -85,11 +83,9 @@ function show(tx) {
|
|||||||
method: tx.method || null,
|
method: tx.method || null,
|
||||||
contractAddress: tx.contractAddress || null,
|
contractAddress: tx.contractAddress || null,
|
||||||
// The network the history entry was read from. The type line and
|
// The network the history entry was read from. The type line and
|
||||||
// the fee are in its native currency, not the active network's,
|
// the fee are in its native currency, not the active network's:
|
||||||
// the links open its explorer, and the block, network details and
|
// a site can switch the active network before a later popup
|
||||||
// calldata are read from its Blockscout: the active network can
|
// shows this screen again.
|
||||||
// change, in Settings or when the user approves a site's request,
|
|
||||||
// before a later popup shows this screen again.
|
|
||||||
chainId: tx.chainId,
|
chainId: tx.chainId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -99,24 +95,18 @@ function show(tx) {
|
|||||||
function render() {
|
function render() {
|
||||||
const tx = state.viewData.tx;
|
const tx = state.viewData.tx;
|
||||||
if (!tx) return;
|
if (!tx) return;
|
||||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
|
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||||
|
|
||||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||||
tx.from,
|
tx.from,
|
||||||
tx.fromEns,
|
tx.fromEns,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
tx.chainId,
|
|
||||||
);
|
);
|
||||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
||||||
if (tx.to) {
|
if (tx.to) {
|
||||||
const toTitle = addressTitle(tx.to, state.wallets);
|
const toTitle = addressTitle(tx.to, state.wallets);
|
||||||
$("tx-detail-to").innerHTML = txAddressHtml(
|
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||||
tx.to,
|
|
||||||
tx.toEns,
|
|
||||||
toTitle,
|
|
||||||
tx.chainId,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||||
}
|
}
|
||||||
@@ -155,7 +145,7 @@ function render() {
|
|||||||
if (tokenContractSection && tokenContractEl) {
|
if (tokenContractSection && tokenContractEl) {
|
||||||
if (tx.contractAddress) {
|
if (tx.contractAddress) {
|
||||||
const dot = addressDotHtml(tx.contractAddress);
|
const dot = addressDotHtml(tx.contractAddress);
|
||||||
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
|
const link = explorerUrl("token", tx.contractAddress);
|
||||||
// Hand-rolled rather than renderAddressHtml() because the
|
// Hand-rolled rather than renderAddressHtml() because the
|
||||||
// link goes to the explorer's /token/ page, not /address/.
|
// link goes to the explorer's /token/ page, not /address/.
|
||||||
// Same two-row shape though: dot and link on the strip, the
|
// Same two-row shape though: dot and link on the strip, the
|
||||||
@@ -173,8 +163,7 @@ function render() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the calldata and raw data sections. loadFullTxDetails() shows them
|
// Hide calldata and raw data sections; always fetch full tx details
|
||||||
// once it has their data.
|
|
||||||
const calldataSection = $("tx-detail-calldata-section");
|
const calldataSection = $("tx-detail-calldata-section");
|
||||||
if (calldataSection) calldataSection.classList.add("hidden");
|
if (calldataSection) calldataSection.classList.add("hidden");
|
||||||
const rawDataSection = $("tx-detail-rawdata-section");
|
const rawDataSection = $("tx-detail-rawdata-section");
|
||||||
@@ -193,13 +182,7 @@ function render() {
|
|||||||
if (el) el.classList.add("hidden");
|
if (el) el.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The block, network details, decoded details and raw data come from
|
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
|
||||||
// Blockscout. While the setting is off, this line says why none is shown.
|
|
||||||
$("tx-detail-blockscout-note").classList.toggle(
|
|
||||||
"hidden",
|
|
||||||
!!state.useBlockscout,
|
|
||||||
);
|
|
||||||
loadFullTxDetails(tx.hash, tx.chainId);
|
|
||||||
|
|
||||||
const isoStr = isoDate(tx.timestamp);
|
const isoStr = isoDate(tx.timestamp);
|
||||||
$("tx-detail-time").innerHTML =
|
$("tx-detail-time").innerHTML =
|
||||||
@@ -220,11 +203,7 @@ function showDetailField(sectionId, contentId, value) {
|
|||||||
function populateOnChainDetails(txData, chainId) {
|
function populateOnChainDetails(txData, chainId) {
|
||||||
// Block number
|
// Block number
|
||||||
if (txData.block_number != null) {
|
if (txData.block_number != null) {
|
||||||
const blockLink = explorerUrl(
|
const blockLink = explorerUrl("block", String(txData.block_number));
|
||||||
"block",
|
|
||||||
String(txData.block_number),
|
|
||||||
chainId,
|
|
||||||
);
|
|
||||||
const blockSection = $("tx-detail-block-section");
|
const blockSection = $("tx-detail-block-section");
|
||||||
const blockEl = $("tx-detail-block");
|
const blockEl = $("tx-detail-block");
|
||||||
if (blockSection && blockEl) {
|
if (blockSection && blockEl) {
|
||||||
@@ -311,12 +290,7 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
async function loadFullTxDetails(txHash, toAddress, chainId) {
|
||||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
|
||||||
// asked. An answer that lands after the setting went off is dropped
|
|
||||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
|
||||||
async function loadFullTxDetails(txHash, chainId) {
|
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
const actionEl = $("tx-detail-calldata-action");
|
const actionEl = $("tx-detail-calldata-action");
|
||||||
const detailsEl = $("tx-detail-calldata-details");
|
const detailsEl = $("tx-detail-calldata-details");
|
||||||
@@ -326,13 +300,11 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (!section || !actionEl || !detailsEl) return;
|
if (!section || !actionEl || !detailsEl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { blockscoutUrl } = endpointsByChainId(chainId);
|
|
||||||
const resp = await debugFetch(
|
const resp = await debugFetch(
|
||||||
blockscoutUrl + "/transactions/" + txHash,
|
state.blockscoutUrl + "/transactions/" + txHash,
|
||||||
);
|
);
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData, chainId);
|
||||||
@@ -340,17 +312,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
const inputData = txData.raw_input || txData.input || null;
|
const inputData = txData.raw_input || txData.input || null;
|
||||||
if (!inputData || inputData === "0x") return;
|
if (!inputData || inputData === "0x") return;
|
||||||
|
|
||||||
// Decoded against the address the transaction was sent to, as the
|
const decoded = decodeCalldata(inputData, toAddress || "");
|
||||||
// explorer reports it, not the history entry's `to`: for a token
|
|
||||||
// transfer that is the transfer's recipient, not the token contract.
|
|
||||||
// On the network the transaction was sent on, whose Blockscout was
|
|
||||||
// just asked, and from the sender it reports.
|
|
||||||
const decoded = decodeCalldata(
|
|
||||||
inputData,
|
|
||||||
txData.to?.hash || "",
|
|
||||||
chainId,
|
|
||||||
txData.from?.hash || "",
|
|
||||||
);
|
|
||||||
if (decoded) {
|
if (decoded) {
|
||||||
// Render decoded calldata matching approval view style
|
// Render decoded calldata matching approval view style
|
||||||
actionEl.textContent = decoded.name;
|
actionEl.textContent = decoded.name;
|
||||||
@@ -367,9 +329,9 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (tokenSymbol) {
|
if (tokenSymbol) {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||||
}
|
}
|
||||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
detailsHtml += renderAddressHtml(d.address);
|
||||||
} else if (d.address) {
|
} else if (d.address) {
|
||||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
detailsHtml += renderAddressHtml(d.address);
|
||||||
} else {
|
} else {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-78
@@ -14,14 +14,10 @@ const {
|
|||||||
displaySymbol,
|
displaySymbol,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
tokenLabel,
|
tokenLabel,
|
||||||
blockscoutOffTokenNoteHtml,
|
|
||||||
reviewedNetworkProvider,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const {
|
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||||
SUPPORTED_CHAIN_IDS,
|
const { getProvider } = require("../../shared/balances");
|
||||||
nativeCurrencyByChainId,
|
|
||||||
} = require("../../shared/networks");
|
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
// Receipt poll cadence and the deadline after which the wait is reported as
|
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||||
@@ -65,22 +61,21 @@ function endWait() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
// A contract creation reaches these screens with `to` as "" (approval.js
|
||||||
// writes `to: toAddr || ""`). The links below open the explorer of the network
|
// writes `to: toAddr || ""`).
|
||||||
// with `chainId`, or of the active network when none is given.
|
function toAddressHtml(address) {
|
||||||
function toAddressHtml(address, chainId) {
|
|
||||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
||||||
const title = addressTitle(address, state.wallets);
|
const title = addressTitle(address, state.wallets);
|
||||||
return renderAddressHtml(address, { title, chainId });
|
return renderAddressHtml(address, { title });
|
||||||
}
|
}
|
||||||
|
|
||||||
function txHashHtml(hash, chainId) {
|
function txHashHtml(hash) {
|
||||||
const link = explorerUrl("tx", hash, chainId);
|
const link = explorerUrl("tx", hash);
|
||||||
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockNumberHtml(blockNumber, chainId) {
|
function blockNumberHtml(blockNumber) {
|
||||||
const num = String(blockNumber);
|
const num = String(blockNumber);
|
||||||
const link = explorerUrl("block", num, chainId);
|
const link = explorerUrl("block", num);
|
||||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,27 +89,15 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
|
|
||||||
// A native amount, here and on the success and error screens, is in the
|
// A native amount, here and on the success and error screens, is in the
|
||||||
// native currency of txInfo.chainId, the network the transaction was sent
|
// native currency of txInfo.chainId, the network the transaction was sent
|
||||||
// on, not the active network's: the active network can change, in
|
// on, not the active network's: a site can switch the active network
|
||||||
// Settings or when the user approves a site's request, while this screen
|
// while this screen is open or before a later popup resumes it.
|
||||||
// is open or before a later popup resumes it. The links on all three
|
const symbol =
|
||||||
// screens open that network's explorer, and this screen's receipt is
|
txInfo.token === "ETH"
|
||||||
// asked of that network's node, for the same reason. A null token is a
|
? nativeCurrencyByChainId(txInfo.chainId)
|
||||||
// swap whose amount was not decoded (see showTxApproval() in
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
// approval.js): the amount says so, and no symbol follows it.
|
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||||
let symbol = "";
|
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
||||||
if (txInfo.token === "ETH") {
|
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
|
||||||
} else if (txInfo.token !== null) {
|
|
||||||
symbol = displaySymbol(txInfo.tokenSymbol || "?");
|
|
||||||
}
|
|
||||||
$("wait-tx-summary").textContent =
|
|
||||||
txInfo.amount + (symbol ? " " + symbol : "");
|
|
||||||
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
|
|
||||||
txInfo.token,
|
|
||||||
txInfo.chainId,
|
|
||||||
);
|
|
||||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
|
||||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
|
||||||
attachCopyHandlers("view-wait-tx");
|
attachCopyHandlers("view-wait-tx");
|
||||||
|
|
||||||
// Persisted so closing and reopening the popup resumes this wait
|
// Persisted so closing and reopening the popup resumes this wait
|
||||||
@@ -139,7 +122,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
renderElapsed();
|
renderElapsed();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const provider = reviewedNetworkProvider(txInfo);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
let consecutiveFailures = 0;
|
let consecutiveFailures = 0;
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
@@ -218,11 +201,9 @@ function showWait(txInfo, txHash) {
|
|||||||
// an object merely missing one of them throws a TypeError out of
|
// an object merely missing one of them throws a TypeError out of
|
||||||
// restoreView() — which init() does not guard, skipping the rest of popup
|
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||||
// init and leaving wait-tx on screen with no back control. A non-numeric
|
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
|
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
|
||||||
// the node the receipt is asked of and the explorer the links open, so it has
|
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
|
||||||
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
|
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
|
||||||
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
|
|
||||||
// Everything else only compares and coalesces them, and tokenSymbol is null for
|
|
||||||
// ETH.
|
// ETH.
|
||||||
function restoreWait() {
|
function restoreWait() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -239,7 +220,6 @@ function restoreWait() {
|
|||||||
// path itself created.
|
// path itself created.
|
||||||
if (typeof info.to !== "string") return false;
|
if (typeof info.to !== "string") return false;
|
||||||
if (typeof info.amount !== "string") return false;
|
if (typeof info.amount !== "string") return false;
|
||||||
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
|
|
||||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -250,13 +230,10 @@ function restoreWait() {
|
|||||||
function showSuccess(txInfo, txHash, blockNumber) {
|
function showSuccess(txInfo, txHash, blockNumber) {
|
||||||
endWait();
|
endWait();
|
||||||
|
|
||||||
// As on the wait screen, a null token has no symbol.
|
const symbol =
|
||||||
let symbol = "";
|
txInfo.token === "ETH"
|
||||||
if (txInfo.token === "ETH") {
|
? nativeCurrencyByChainId(txInfo.chainId)
|
||||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
} else if (txInfo.token !== null) {
|
|
||||||
symbol = displaySymbol(txInfo.tokenSymbol || "?");
|
|
||||||
}
|
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
@@ -264,13 +241,12 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
|||||||
hash: txHash,
|
hash: txHash,
|
||||||
blockNumber: blockNumber,
|
blockNumber: blockNumber,
|
||||||
decoded: txInfo.decoded || null,
|
decoded: txInfo.decoded || null,
|
||||||
chainId: txInfo.chainId,
|
|
||||||
};
|
};
|
||||||
renderSuccess();
|
renderSuccess();
|
||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodedDetailsHtml(decoded, chainId) {
|
function decodedDetailsHtml(decoded) {
|
||||||
if (!decoded || !decoded.details) return "";
|
if (!decoded || !decoded.details) return "";
|
||||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||||
if (decoded.name) {
|
if (decoded.name) {
|
||||||
@@ -286,12 +262,11 @@ function decodedDetailsHtml(decoded, chainId) {
|
|||||||
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
||||||
if (d.address) {
|
if (d.address) {
|
||||||
if (d.isToken) {
|
if (d.isToken) {
|
||||||
const sym = tokenLabel(d.address, chainId) || "Unknown token";
|
const sym = tokenLabel(d.address) || "Unknown token";
|
||||||
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
|
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
|
||||||
html += blockscoutOffTokenNoteHtml(d.address, chainId);
|
html += toAddressHtml(d.address);
|
||||||
html += toAddressHtml(d.address, chainId);
|
|
||||||
} else {
|
} else {
|
||||||
html += toAddressHtml(d.address, chainId);
|
html += toAddressHtml(d.address);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||||
@@ -318,18 +293,17 @@ function renderSuccess() {
|
|||||||
} else {
|
} else {
|
||||||
summarySection.classList.remove("hidden");
|
summarySection.classList.remove("hidden");
|
||||||
toSection.classList.remove("hidden");
|
toSection.classList.remove("hidden");
|
||||||
$("success-tx-summary").textContent =
|
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
|
||||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
$("success-tx-to").innerHTML = toAddressHtml(d.to);
|
||||||
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
|
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
|
||||||
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
|
||||||
|
|
||||||
// Show decoded calldata details if present
|
// Show decoded calldata details if present
|
||||||
const decodedEl = $("success-tx-decoded");
|
const decodedEl = $("success-tx-decoded");
|
||||||
if (decodedEl && hasDecoded) {
|
if (decodedEl && hasDecoded) {
|
||||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
|
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
|
||||||
decodedEl.classList.remove("hidden");
|
decodedEl.classList.remove("hidden");
|
||||||
} else if (decodedEl) {
|
} else if (decodedEl) {
|
||||||
decodedEl.classList.add("hidden");
|
decodedEl.classList.add("hidden");
|
||||||
@@ -342,21 +316,16 @@ function renderSuccess() {
|
|||||||
function showError(txInfo, txHash, message) {
|
function showError(txInfo, txHash, message) {
|
||||||
endWait();
|
endWait();
|
||||||
|
|
||||||
// As on the wait screen, a null token has no symbol.
|
const symbol =
|
||||||
let symbol = "";
|
txInfo.token === "ETH"
|
||||||
if (txInfo.token === "ETH") {
|
? nativeCurrencyByChainId(txInfo.chainId)
|
||||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
} else if (txInfo.token !== null) {
|
|
||||||
symbol = displaySymbol(txInfo.tokenSymbol || "?");
|
|
||||||
}
|
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
token: txInfo.token,
|
|
||||||
to: txInfo.to,
|
to: txInfo.to,
|
||||||
hash: txHash || null,
|
hash: txHash || null,
|
||||||
message: message,
|
message: message,
|
||||||
chainId: txInfo.chainId,
|
|
||||||
};
|
};
|
||||||
renderError();
|
renderError();
|
||||||
}
|
}
|
||||||
@@ -364,17 +333,12 @@ function showError(txInfo, txHash, message) {
|
|||||||
function renderError() {
|
function renderError() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
if (!d || !d.message) return;
|
if (!d || !d.message) return;
|
||||||
$("error-tx-summary").textContent =
|
$("error-tx-summary").textContent = d.amount + " " + d.symbol;
|
||||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
$("error-tx-to").innerHTML = toAddressHtml(d.to);
|
||||||
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
|
|
||||||
d.token,
|
|
||||||
d.chainId,
|
|
||||||
);
|
|
||||||
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
|
||||||
$("error-tx-message").textContent = d.message;
|
$("error-tx-message").textContent = d.message;
|
||||||
|
|
||||||
if (d.hash) {
|
if (d.hash) {
|
||||||
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
$("error-tx-hash").innerHTML = txHashHtml(d.hash);
|
||||||
$("error-tx-hash-section").classList.remove("hidden");
|
$("error-tx-hash-section").classList.remove("hidden");
|
||||||
attachCopyHandlers("view-error-tx");
|
attachCopyHandlers("view-error-tx");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,37 +1,7 @@
|
|||||||
// The first screen, shown while there is no wallet. It offers "Use Blockscout"
|
const { $ } = require("./helpers");
|
||||||
// and "Check recipients on Etherscan", which Settings offers too. Both are off
|
|
||||||
// until the user ticks them.
|
|
||||||
|
|
||||||
const { $, showView } = require("./helpers");
|
|
||||||
const { state, saveState } = require("../../shared/state");
|
|
||||||
|
|
||||||
function show() {
|
|
||||||
$("welcome-use-blockscout").checked = state.useBlockscout;
|
|
||||||
$("welcome-check-recipients-on-etherscan").checked =
|
|
||||||
state.checkRecipientsOnEtherscan;
|
|
||||||
showView("welcome");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each change is made inside the save, holding the lock, as Settings makes it.
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
|
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
|
||||||
|
|
||||||
$("welcome-use-blockscout").addEventListener("change", async () => {
|
|
||||||
const on = $("welcome-use-blockscout").checked;
|
|
||||||
await saveState(() => {
|
|
||||||
state.useBlockscout = on;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("welcome-check-recipients-on-etherscan").addEventListener(
|
|
||||||
"change",
|
|
||||||
async () => {
|
|
||||||
const on = $("welcome-check-recipients-on-etherscan").checked;
|
|
||||||
await saveState(() => {
|
|
||||||
state.checkRecipientsOnEtherscan = on;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init };
|
||||||
|
|||||||
@@ -51,15 +51,12 @@ function getLocalWarnings(address, options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an address against local lists AND via RPC queries.
|
* Check an address against local lists AND via RPC queries.
|
||||||
* Async — performs network calls to check contract status, tx count and
|
* Async — performs network calls to check contract status and tx history.
|
||||||
* balance.
|
|
||||||
*
|
*
|
||||||
* @param {string} address - The target address to check.
|
* @param {string} address - The target address to check.
|
||||||
* @param {object} provider - An ethers.js provider instance.
|
* @param {object} provider - An ethers.js provider instance.
|
||||||
* @param {object} [options] - Optional context.
|
* @param {object} [options] - Optional context.
|
||||||
* @param {string} [options.fromAddress] - Sender address (for self-send check).
|
* @param {string} [options.fromAddress] - Sender address (for self-send check).
|
||||||
* @param {string} [options.chainId] - Chain id of the network whose explorer
|
|
||||||
* the Etherscan label check asks; the active network's when omitted.
|
|
||||||
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
|
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
|
||||||
*/
|
*/
|
||||||
async function getFullWarnings(address, provider, options = {}) {
|
async function getFullWarnings(address, provider, options = {}) {
|
||||||
@@ -81,30 +78,21 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
log.errorf("contract check failed:", e.shortMessage || e.message);
|
log.errorf("contract check failed:", e.shortMessage || e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The transaction count is the number of transactions the address has
|
// Skip tx count check for contracts — they may legitimately have
|
||||||
// sent, so an address that has only ever received has a count of zero.
|
// zero inbound EOA transactions.
|
||||||
// Native token it received stays in its balance until it sends a
|
|
||||||
// transaction, so the warning also needs a zero balance. A contract is
|
|
||||||
// skipped: it has its own warning above.
|
|
||||||
if (!isContract) {
|
if (!isContract) {
|
||||||
try {
|
try {
|
||||||
const txCount = await provider.getTransactionCount(address);
|
const txCount = await provider.getTransactionCount(address);
|
||||||
if (txCount === 0) {
|
if (txCount === 0) {
|
||||||
const balance = await provider.getBalance(address);
|
warnings.push({
|
||||||
if (balance === 0n) {
|
type: "new-address",
|
||||||
warnings.push({
|
message:
|
||||||
type: "new-address",
|
"This address has never sent a transaction. Double-check it is correct.",
|
||||||
message:
|
severity: "info",
|
||||||
"This address has never sent a transaction and holds none of the network's native token. Double-check it is correct.",
|
});
|
||||||
severity: "info",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf(
|
log.errorf("tx count check failed:", e.shortMessage || e.message);
|
||||||
"tx count or balance check failed:",
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +100,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
// Runs for ALL addresses including contracts, since many dangerous
|
// Runs for ALL addresses including contracts, since many dangerous
|
||||||
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
|
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
|
||||||
try {
|
try {
|
||||||
const etherscanWarning = await checkEtherscanLabel(
|
const etherscanWarning = await checkEtherscanLabel(address);
|
||||||
address,
|
|
||||||
options.chainId,
|
|
||||||
);
|
|
||||||
if (etherscanWarning) {
|
if (etherscanWarning) {
|
||||||
warnings.push(etherscanWarning);
|
warnings.push(etherscanWarning);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-76
@@ -1,14 +1,13 @@
|
|||||||
// Preparation of the transaction an approval screen displays.
|
// Preparation of the transaction an approval screen displays.
|
||||||
//
|
//
|
||||||
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
|
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
|
||||||
// sometimes the gas limit. The nonce, the fees, and the gas limit when the
|
// The nonce, the gas limit and the fees have to be filled in from the network
|
||||||
// site gives none have to be filled in from the network before anything can
|
// before anything can be signed, and whoever fills them in decides what the
|
||||||
// be signed, and whoever fills them in decides what the user is shown. That
|
// user is shown. That work used to happen in the popup, after the user had
|
||||||
// work used to happen in the popup, after the user had already approved: the
|
// already approved: the numbers on the approval screen came from the popup and
|
||||||
// numbers on the approval screen came from the popup and were compared against
|
// were compared against nothing, so a compromised popup could display one fee
|
||||||
// nothing, so a compromised popup could display one fee and sign another, and
|
// and sign another, and the ceilings in approvalVerify.js were all that stood
|
||||||
// the ceilings in approvalVerify.js were all that stood between the user and a
|
// between the user and a fee that hands the validator the balance.
|
||||||
// fee that hands the validator the balance.
|
|
||||||
//
|
//
|
||||||
// So it happens here instead, in the background, before the approval window is
|
// So it happens here instead, in the background, before the approval window is
|
||||||
// opened. The background populates the transaction, shows that object, and
|
// opened. The background populates the transaction, shows that object, and
|
||||||
@@ -32,7 +31,6 @@ const {
|
|||||||
VoidSigner,
|
VoidSigner,
|
||||||
accessListify,
|
accessListify,
|
||||||
getAddress,
|
getAddress,
|
||||||
getBigInt,
|
|
||||||
getBytes,
|
getBytes,
|
||||||
hexlify,
|
hexlify,
|
||||||
toQuantity,
|
toQuantity,
|
||||||
@@ -41,7 +39,6 @@ const {
|
|||||||
ALLOWED_TX_TYPES,
|
ALLOWED_TX_TYPES,
|
||||||
SERIALIZED_FIELDS,
|
SERIALIZED_FIELDS,
|
||||||
assertWithinCeilings,
|
assertWithinCeilings,
|
||||||
nodeError,
|
|
||||||
} = require("./approvalVerify");
|
} = require("./approvalVerify");
|
||||||
|
|
||||||
// How long the population may take before the request is failed back to the
|
// How long the population may take before the request is failed back to the
|
||||||
@@ -50,11 +47,10 @@ const {
|
|||||||
// minutes long, which is not a wait anyone will sit through.
|
// minutes long, which is not a wait anyone will sit through.
|
||||||
const POPULATE_TIMEOUT_MS = 20000;
|
const POPULATE_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
// The request fields taken from the page, plus `gas`, which requestFrom()
|
// The request fields taken from the page. Anything else is dropped rather than
|
||||||
// takes as the gas limit. Anything else is dropped rather than passed to
|
// passed to ethers: the object is page-controlled, and a future ethers that
|
||||||
// ethers: the object is page-controlled, and a future ethers that learns to
|
// learns to carry a new transaction field must not start picking one up out of
|
||||||
// carry a new transaction field must not start picking one up out of it
|
// it without this module knowing.
|
||||||
// without this module knowing.
|
|
||||||
//
|
//
|
||||||
// The nonce is not taken from the page; it is always the account's next nonce
|
// The nonce is not taken from the page; it is always the account's next nonce
|
||||||
// from the network. A page that chose it could replace one of the user's
|
// from the network. A page that chose it could replace one of the user's
|
||||||
@@ -73,43 +69,21 @@ const REQUEST_FIELDS = [
|
|||||||
"type",
|
"type",
|
||||||
];
|
];
|
||||||
|
|
||||||
// EIP-1474's code for invalid parameters: what the page is given when the
|
|
||||||
// site asked for a transaction this wallet refuses as it was asked for.
|
|
||||||
const INVALID_PARAMS_CODE = -32602;
|
|
||||||
|
|
||||||
// Why no approval can be raised. The message is the sentence the page is
|
|
||||||
// shown. `code` and `data` are the JSON-RPC ones it is given with it: the
|
|
||||||
// node's own, unchanged, when the node refused the transaction, so a site can
|
|
||||||
// decode a revert; INVALID_PARAMS_CODE when the site's request is refused;
|
|
||||||
// none when the failure is the wallet's own, which the background answers as
|
|
||||||
// an internal error.
|
|
||||||
class ApprovalPrepareError extends Error {
|
class ApprovalPrepareError extends Error {
|
||||||
constructor(message, code, data) {
|
constructor(message) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApprovalPrepareError";
|
this.name = "ApprovalPrepareError";
|
||||||
if (code !== undefined) this.code = code;
|
|
||||||
if (data !== undefined) this.data = data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message, code, data) {
|
function fail(message) {
|
||||||
return new ApprovalPrepareError(message, code, data);
|
return new ApprovalPrepareError(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function present(v) {
|
function present(v) {
|
||||||
return v !== null && v !== undefined && v !== "";
|
return v !== null && v !== undefined && v !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether two quantities from the page are the same number. One that is not a
|
|
||||||
// number matches nothing.
|
|
||||||
function sameQuantity(a, b) {
|
|
||||||
try {
|
|
||||||
return getBigInt(a) === getBigInt(b);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These strings reach the user through the requesting page, so they are full
|
// These strings reach the user through the requesting page, so they are full
|
||||||
// sentences even when the tail of one came from ethers or from the node.
|
// sentences even when the tail of one came from ethers or from the node.
|
||||||
function sentence(text) {
|
function sentence(text) {
|
||||||
@@ -137,28 +111,12 @@ function requestFrom(txParams, from) {
|
|||||||
for (const key of REQUEST_FIELDS) {
|
for (const key of REQUEST_FIELDS) {
|
||||||
if (present(txParams[key])) request[key] = txParams[key];
|
if (present(txParams[key])) request[key] = txParams[key];
|
||||||
}
|
}
|
||||||
// JSON-RPC calls the gas limit `gas`, and that is the name sites send.
|
|
||||||
// ethers only reads `gasLimit`: left as `gas` it would be dropped and an
|
|
||||||
// estimate signed in its place.
|
|
||||||
if (present(txParams.gas)) {
|
|
||||||
if (
|
|
||||||
present(request.gasLimit) &&
|
|
||||||
!sameQuantity(request.gasLimit, txParams.gas)
|
|
||||||
) {
|
|
||||||
throw fail(
|
|
||||||
"The site asked for two different gas limits for this transaction.",
|
|
||||||
INVALID_PARAMS_CODE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
request.gasLimit = txParams.gas;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
present(request.type) &&
|
present(request.type) &&
|
||||||
!ALLOWED_TX_TYPES.includes(Number(request.type))
|
!ALLOWED_TX_TYPES.includes(Number(request.type))
|
||||||
) {
|
) {
|
||||||
throw fail(
|
throw fail(
|
||||||
"The site asked for a transaction of a type this wallet does not sign.",
|
"The site asked for a transaction of a type this wallet does not sign.",
|
||||||
INVALID_PARAMS_CODE,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return request;
|
return request;
|
||||||
@@ -216,7 +174,6 @@ async function prepareApprovalTx(provider, from, txParams) {
|
|||||||
throw fail("There is no active address to send this transaction from.");
|
throw fail("There is no active address to send this transaction from.");
|
||||||
}
|
}
|
||||||
const request = requestFrom(txParams || {}, from);
|
const request = requestFrom(txParams || {}, from);
|
||||||
const signer = new VoidSigner(getAddress(from), provider);
|
|
||||||
|
|
||||||
let populated;
|
let populated;
|
||||||
try {
|
try {
|
||||||
@@ -224,28 +181,15 @@ async function prepareApprovalTx(provider, from, txParams) {
|
|||||||
// nonce, gas, fee and chain id are populated exactly as they were when
|
// nonce, gas, fee and chain id are populated exactly as they were when
|
||||||
// the popup did this. VoidSigner cannot sign, which is the point: the
|
// the popup did this. VoidSigner cannot sign, which is the point: the
|
||||||
// background prepares, the popup signs.
|
// background prepares, the popup signs.
|
||||||
//
|
|
||||||
// ethers asks the node for a gas estimate only when the request has
|
|
||||||
// no gas limit, and that estimate is the only check that the
|
|
||||||
// transaction does not revert. So a request carrying the site's gas
|
|
||||||
// limit is estimated first, with that limit: one that reverts, or
|
|
||||||
// needs more gas than the site gave it, fails here as well. The
|
|
||||||
// site's gas limit is still the one populated.
|
|
||||||
populated = await withTimeout(
|
populated = await withTimeout(
|
||||||
(async () => {
|
new VoidSigner(getAddress(from), provider).populateTransaction(
|
||||||
if (present(request.gasLimit)) {
|
request,
|
||||||
await signer.estimateGas(request);
|
),
|
||||||
}
|
|
||||||
return signer.populateTransaction(request);
|
|
||||||
})(),
|
|
||||||
POPULATE_TIMEOUT_MS,
|
POPULATE_TIMEOUT_MS,
|
||||||
"The transaction could not be prepared: the network did not answer in time.",
|
"The transaction could not be prepared: the network did not answer in time.",
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApprovalPrepareError) throw e;
|
if (e instanceof ApprovalPrepareError) throw e;
|
||||||
// A refusal from the node, such as a reverting gas estimate, keeps
|
|
||||||
// the node's code and revert data.
|
|
||||||
const fromNode = nodeError(e);
|
|
||||||
throw fail(
|
throw fail(
|
||||||
sentence(
|
sentence(
|
||||||
"The transaction could not be prepared: " +
|
"The transaction could not be prepared: " +
|
||||||
@@ -253,8 +197,6 @@ async function prepareApprovalTx(provider, from, txParams) {
|
|||||||
e.message ||
|
e.message ||
|
||||||
"the network did not answer"),
|
"the network did not answer"),
|
||||||
),
|
),
|
||||||
fromNode?.code,
|
|
||||||
fromNode?.data,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,8 @@ const {
|
|||||||
accessListify,
|
accessListify,
|
||||||
formatEther,
|
formatEther,
|
||||||
getAddress,
|
getAddress,
|
||||||
getBigInt,
|
|
||||||
getBytes,
|
getBytes,
|
||||||
toQuantity,
|
toQuantity,
|
||||||
TypedDataEncoder,
|
|
||||||
verifyMessage,
|
verifyMessage,
|
||||||
verifyTypedData,
|
verifyTypedData,
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
@@ -637,90 +635,6 @@ function verifySignature(signParams, signature, expectedFrom) {
|
|||||||
return recovered;
|
return recovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The type ethers will sign typed data as. ethers does not read the page's
|
|
||||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
|
||||||
// refers to. Throws when the types name no such single struct, which ethers
|
|
||||||
// would refuse to sign as well.
|
|
||||||
function signedPrimaryType(types) {
|
|
||||||
const structs = { ...types };
|
|
||||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
|
||||||
delete structs.EIP712Domain;
|
|
||||||
return TypedDataEncoder.getPrimaryType(structs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Why a typed data message cannot be signed as the screen shows it, as a
|
|
||||||
// sentence, or null when it can. ethers signs a `bool` field by whether its
|
|
||||||
// value is truthy, so the string "false" is signed as true and a field left
|
|
||||||
// out as false. Each field that `type` declares, at every level of nesting,
|
|
||||||
// must be one of the value's own keys, and each `bool` must be true or false.
|
|
||||||
// Called with the type ethers signs, the message, and a `path` of "".
|
|
||||||
function typedDataMessageRefusal(types, type, value, path) {
|
|
||||||
const itemType = type.replace(/\[\d*\]$/, "");
|
|
||||||
if (itemType !== type) {
|
|
||||||
// ethers refuses to sign a list field that is not a list.
|
|
||||||
if (!Array.isArray(value)) return null;
|
|
||||||
for (let i = 0; i < value.length; i++) {
|
|
||||||
const refusal = typedDataMessageRefusal(
|
|
||||||
types,
|
|
||||||
itemType,
|
|
||||||
value[i],
|
|
||||||
`${path}[${i}]`,
|
|
||||||
);
|
|
||||||
if (refusal) return refusal;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (type === "bool" && value !== true && value !== false) {
|
|
||||||
return `The field "${path}" is not true or false, so this typed data cannot be signed.`;
|
|
||||||
}
|
|
||||||
for (const field of types[type] || []) {
|
|
||||||
const at = path ? `${path}.${field.name}` : field.name;
|
|
||||||
// An own key only: a plain lookup of `constructor` finds a function.
|
|
||||||
const present =
|
|
||||||
value !== null &&
|
|
||||||
typeof value === "object" &&
|
|
||||||
Object.prototype.hasOwnProperty.call(value, field.name);
|
|
||||||
if (!present) {
|
|
||||||
return `The field "${at}" is missing from the message, so this typed data cannot be signed.`;
|
|
||||||
}
|
|
||||||
const refusal = typedDataMessageRefusal(
|
|
||||||
types,
|
|
||||||
field.type,
|
|
||||||
value[field.name],
|
|
||||||
at,
|
|
||||||
);
|
|
||||||
if (refusal) return refusal;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Why typed data cannot be signed while `network` is the active network, as a
|
|
||||||
// sentence, or null when it can. EIP-712 has a wallet refuse typed data whose
|
|
||||||
// domain names another chain, because the signature is valid on that chain.
|
|
||||||
// ethers signs no chain id when the domain's is null or absent, and reads any
|
|
||||||
// other with getBigInt(), so 1, "1" and "0x1" are the same chain here too.
|
|
||||||
function typedDataChainRefusal(domain, network) {
|
|
||||||
const chainId = domain?.chainId;
|
|
||||||
if (chainId === undefined || chainId === null) return null;
|
|
||||||
let named;
|
|
||||||
try {
|
|
||||||
named = getBigInt(chainId);
|
|
||||||
} catch {
|
|
||||||
return "This typed data cannot be read, so it cannot be signed.";
|
|
||||||
}
|
|
||||||
const active = getBigInt(network.chainId);
|
|
||||||
if (named === active) return null;
|
|
||||||
return (
|
|
||||||
"This typed data is for chain id " +
|
|
||||||
named +
|
|
||||||
", but the active network is " +
|
|
||||||
network.name +
|
|
||||||
" (chain id " +
|
|
||||||
active +
|
|
||||||
"), so it cannot be signed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The stage a transaction approval failed at. Which stage it is decides
|
// The stage a transaction approval failed at. Which stage it is decides
|
||||||
// whether the approval survives the failure.
|
// whether the approval survives the failure.
|
||||||
const TX_STAGE_SIGN = "sign";
|
const TX_STAGE_SIGN = "sign";
|
||||||
@@ -746,15 +660,6 @@ function errorText(err) {
|
|||||||
return "The transaction could not be sent.";
|
return "The transaction could not be sent.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The JSON-RPC error object the node answered with, `{ code, message, data }`,
|
|
||||||
// out of the error ethers threw for that answer. ethers keeps it under
|
|
||||||
// `info.error`, or under `error` when it could not classify it. Null when
|
|
||||||
// there is none, as for a node that could not be reached.
|
|
||||||
function nodeError(err) {
|
|
||||||
if (!err || typeof err !== "object") return null;
|
|
||||||
return err.error || (err.info && err.info.error) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every string a failure might carry its reason in. ethers reports the node's
|
// Every string a failure might carry its reason in. ethers reports the node's
|
||||||
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
|
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
|
||||||
// nested under `error` or `info.error` with the node's message intact, and the
|
// nested under `error` or `info.error` with the node's message intact, and the
|
||||||
@@ -766,7 +671,7 @@ function failureTexts(err) {
|
|||||||
for (const text of [err.shortMessage, err.message, err.reason]) {
|
for (const text of [err.shortMessage, err.message, err.reason]) {
|
||||||
if (text) texts.push(String(text));
|
if (text) texts.push(String(text));
|
||||||
}
|
}
|
||||||
const nested = nodeError(err);
|
const nested = err.error || (err.info && err.info.error);
|
||||||
if (nested && nested.message) texts.push(String(nested.message));
|
if (nested && nested.message) texts.push(String(nested.message));
|
||||||
return texts;
|
return texts;
|
||||||
}
|
}
|
||||||
@@ -891,16 +796,12 @@ function describeSigningFailure(response, fallbackMessage) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
verifySignedTx,
|
verifySignedTx,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
signedPrimaryType,
|
|
||||||
typedDataMessageRefusal,
|
|
||||||
typedDataChainRefusal,
|
|
||||||
assertNoForbiddenFields,
|
assertNoForbiddenFields,
|
||||||
assertNothingUnchecked,
|
assertNothingUnchecked,
|
||||||
assertCanonicalBytes,
|
assertCanonicalBytes,
|
||||||
assertWithinCeilings,
|
assertWithinCeilings,
|
||||||
sameAddress,
|
sameAddress,
|
||||||
failureIsRetryable,
|
failureIsRetryable,
|
||||||
nodeError,
|
|
||||||
isNonceCollision,
|
isNonceCollision,
|
||||||
describeTxFailure,
|
describeTxFailure,
|
||||||
describeSigningFailure,
|
describeSigningFailure,
|
||||||
|
|||||||
+21
-187
@@ -1,10 +1,5 @@
|
|||||||
// Balance fetching:
|
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
|
||||||
//
|
// Blockscout, ENS reverse lookup via RPC.
|
||||||
// - ETH balances, and the balances of the tokens the user tracks, via RPC
|
|
||||||
// - the other ERC-20 token balances and the holder counts via Blockscout,
|
|
||||||
// while the user has "Use Blockscout" on. Those balances are dropped when
|
|
||||||
// the setting is off.
|
|
||||||
// - ENS reverse lookup via RPC
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
JsonRpcProvider,
|
JsonRpcProvider,
|
||||||
@@ -22,7 +17,6 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
|||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
const { toDecimals } = require("./transferAmount");
|
const { toDecimals } = require("./transferAmount");
|
||||||
const { resolveTokenDecimals } = require("./approvalAmount");
|
const { resolveTokenDecimals } = require("./approvalAmount");
|
||||||
const { storedSettingIsOn } = require("./storedSettings");
|
|
||||||
|
|
||||||
// Use a static network to skip auto-detection (which can fail and cause
|
// Use a static network to skip auto-detection (which can fail and cause
|
||||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||||
@@ -42,18 +36,9 @@ const { storedSettingIsOn } = require("./storedSettings");
|
|||||||
// ethers knows chains this wallet does not, so an id that is not one of ours
|
// ethers knows chains this wallet does not, so an id that is not one of ours
|
||||||
// is a caller bug and must not resolve to a working provider for some other
|
// is a caller bug and must not resolve to a working provider for some other
|
||||||
// chain.
|
// chain.
|
||||||
//
|
|
||||||
// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. A resolver can
|
|
||||||
// say a name is resolved off-chain and give a web address for it. By default
|
|
||||||
// ethers then fetches that address straight from the browser, sending the name
|
|
||||||
// being looked up. For the reverse lookup of the user's addresses, the name sent
|
|
||||||
// is the user's own (https://git.eeqj.de/sneak/AutistMask/issues/668). With the
|
|
||||||
// flag set, such a name does not resolve.
|
|
||||||
function getProvider(rpcUrl, networkId) {
|
function getProvider(rpcUrl, networkId) {
|
||||||
const net = Network.from(requireNetworkId(networkId).id);
|
const net = Network.from(requireNetworkId(networkId).id);
|
||||||
const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
||||||
provider.disableCcipRead = true;
|
|
||||||
return provider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireNetworkId(networkId) {
|
function requireNetworkId(networkId) {
|
||||||
@@ -95,16 +80,8 @@ function rawUnits(value) {
|
|||||||
|
|
||||||
// Fetch token balances for a single address from Blockscout.
|
// Fetch token balances for a single address from Blockscout.
|
||||||
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
||||||
//
|
|
||||||
// While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
|
|
||||||
// that lands after the setting went off is dropped
|
|
||||||
// (src/shared/storedSettings.js). Either way this returns an empty list, so
|
|
||||||
// only tracked tokens are listed.
|
|
||||||
//
|
|
||||||
// Filters out spam: only shows tokens that are in the known token list,
|
// Filters out spam: only shows tokens that are in the known token list,
|
||||||
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's
|
// explicitly tracked by the user, or have >= 1000 holders.
|
||||||
// balance is read through the RPC endpoint instead, and its row here gives that
|
|
||||||
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
|
|
||||||
//
|
//
|
||||||
// `decimals` and `balance` are each null when the answer is unknown, the same
|
// `decimals` and `balance` are each null when the answer is unknown, the same
|
||||||
// way `holders` already is. Absence is never filled in here: this is the
|
// way `holders` already is. Absence is never filled in here: this is the
|
||||||
@@ -119,7 +96,6 @@ async function fetchTokenBalances(
|
|||||||
trackedTokens,
|
trackedTokens,
|
||||||
signal,
|
signal,
|
||||||
) {
|
) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(
|
const resp = await debugFetch(
|
||||||
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
||||||
@@ -129,7 +105,6 @@ async function fetchTokenBalances(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const items = await resp.json();
|
const items = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
|
||||||
if (!Array.isArray(items)) return null;
|
if (!Array.isArray(items)) return null;
|
||||||
|
|
||||||
const trackedSet = new Set(
|
const trackedSet = new Set(
|
||||||
@@ -230,107 +205,8 @@ async function fetchTokenBalances(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The entries at `address` of the tokens the user tracks, shaped as
|
|
||||||
// fetchTokenBalances() returns them, for the tokens the address holds.
|
|
||||||
//
|
|
||||||
// The balance is read with balanceOf through the RPC endpoint. The name and
|
|
||||||
// symbol are the tracked token's. `decimals` and `holders` are those of
|
|
||||||
// Blockscout's entry for the token in `explorerRequest`, this refresh's
|
|
||||||
// fetchTokenBalances() for the address; while Blockscout fails they are kept
|
|
||||||
// from the token's entry in `previous`, the address's entries before this
|
|
||||||
// refresh. The balance is stated at the scale the screens read the token at
|
|
||||||
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
|
|
||||||
// records, then Blockscout's, as in fetchTokenBalances().
|
|
||||||
//
|
|
||||||
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
|
|
||||||
// balance keeps its figure, or takes Blockscout's entry from this refresh when
|
|
||||||
// it has none. The failure is logged. A balance of zero gets no entry, as a
|
|
||||||
// zero Blockscout reports gets none: the balance lists take a tracked token's
|
|
||||||
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
|
|
||||||
// no entry either, as in fetchTokenBalances(), and its balance is not read.
|
|
||||||
//
|
|
||||||
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
|
|
||||||
// balance was not read, as lowercased contract addresses. A token is unread
|
|
||||||
// when the spoof rule refuses it or its balanceOf fails.
|
|
||||||
//
|
|
||||||
// `signal` is as for fetchTokenBalances().
|
|
||||||
async function fetchTrackedTokenBalances(
|
|
||||||
provider,
|
|
||||||
address,
|
|
||||||
trackedTokens,
|
|
||||||
previous,
|
|
||||||
explorerRequest,
|
|
||||||
signal,
|
|
||||||
) {
|
|
||||||
const listed = trackedTokens.filter(
|
|
||||||
(token) => !isSpoofedSymbol(token.symbol, token.address),
|
|
||||||
);
|
|
||||||
// Each token's balanceOf answer, or null where it failed.
|
|
||||||
const balances = await Promise.all(
|
|
||||||
listed.map(async (token) => {
|
|
||||||
try {
|
|
||||||
const contract = new Contract(
|
|
||||||
token.address,
|
|
||||||
ERC20_ABI,
|
|
||||||
provider,
|
|
||||||
);
|
|
||||||
return await contract.balanceOf(address);
|
|
||||||
} catch (e) {
|
|
||||||
if (!signal?.aborted) {
|
|
||||||
log.errorf(
|
|
||||||
"token balance failed",
|
|
||||||
address,
|
|
||||||
token.address,
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const explorerRows = await explorerRequest;
|
|
||||||
const reported = explorerRows ?? previous;
|
|
||||||
|
|
||||||
const rows = [];
|
|
||||||
const unread = trackedTokens
|
|
||||||
.filter((token) => !listed.includes(token))
|
|
||||||
.map((token) => token.address.toLowerCase());
|
|
||||||
listed.forEach((token, i) => {
|
|
||||||
const lower = token.address.toLowerCase();
|
|
||||||
const entryIn = (list) =>
|
|
||||||
list.find((row) => String(row.address).toLowerCase() === lower);
|
|
||||||
const raw = balances[i];
|
|
||||||
if (raw === null) {
|
|
||||||
unread.push(lower);
|
|
||||||
const kept = entryIn(previous) || entryIn(explorerRows || []);
|
|
||||||
if (kept) rows.push(kept);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (raw === 0n) return;
|
|
||||||
const entry = entryIn(reported);
|
|
||||||
const decimals = entry ? entry.decimals : null;
|
|
||||||
const known = resolveTokenDecimals(lower, { trackedTokens });
|
|
||||||
const scale = known !== null ? known : decimals;
|
|
||||||
rows.push({
|
|
||||||
address: token.address,
|
|
||||||
name: token.name || "",
|
|
||||||
symbol: token.symbol || "???",
|
|
||||||
decimals: decimals,
|
|
||||||
// null when nothing knows the scale: a holding whose amount
|
|
||||||
// cannot be stated, never a zero.
|
|
||||||
balance: scale === null ? null : formatTokenBalance(raw, scale),
|
|
||||||
holders: entry ? entry.holders : null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return { rows, unread };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
||||||
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
|
// `signal` is as for fetchTokenBalances().
|
||||||
// for fetchTokenBalances().
|
|
||||||
//
|
|
||||||
// Resolves with the lowercased contract addresses of the tracked tokens whose
|
|
||||||
// balance it read at every address.
|
|
||||||
async function refreshBalances(
|
async function refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
rpcUrl,
|
rpcUrl,
|
||||||
@@ -341,10 +217,6 @@ async function refreshBalances(
|
|||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const tracked = trackedTokens || [];
|
|
||||||
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
|
|
||||||
// The tracked tokens whose balance was not read at some address.
|
|
||||||
const unread = new Set();
|
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
for (const wallet of wallets) {
|
for (const wallet of wallets) {
|
||||||
@@ -394,45 +266,23 @@ async function refreshBalances(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ERC-20 token balances: the tracked tokens' through the RPC
|
// ERC-20 token balances via Blockscout
|
||||||
// endpoint, and from Blockscout the tokens the user has not
|
|
||||||
// tracked. When Blockscout fails, the untracked rows from before
|
|
||||||
// this refresh stay. While "Use Blockscout" is off, there are no
|
|
||||||
// untracked rows. The Blockscout request is started first and
|
|
||||||
// handed to fetchTrackedTokenBalances() too, which waits for it
|
|
||||||
// after sending its balanceOf reads, so the two run side by side.
|
|
||||||
const previous = addr.tokenBalances || [];
|
|
||||||
const explorerRequest = fetchTokenBalances(
|
|
||||||
addr.address,
|
|
||||||
blockscoutUrl,
|
|
||||||
tracked,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
updates.push(
|
updates.push(
|
||||||
Promise.all([
|
fetchTokenBalances(
|
||||||
fetchTrackedTokenBalances(
|
addr.address,
|
||||||
provider,
|
blockscoutUrl,
|
||||||
addr.address,
|
trackedTokens,
|
||||||
tracked,
|
signal,
|
||||||
previous,
|
).then((balances) => {
|
||||||
explorerRequest,
|
if (balances !== null) {
|
||||||
signal,
|
addr.tokenBalances = balances;
|
||||||
),
|
log.debugf(
|
||||||
explorerRequest,
|
"Token balances",
|
||||||
]).then(([trackedResult, explorerRows]) => {
|
addr.address,
|
||||||
for (const lower of trackedResult.unread) unread.add(lower);
|
balances.length,
|
||||||
const untrackedRows = (explorerRows ?? previous).filter(
|
"tokens",
|
||||||
(row) =>
|
);
|
||||||
!trackedSet.has(String(row.address).toLowerCase()),
|
}
|
||||||
);
|
|
||||||
addr.tokenBalances =
|
|
||||||
trackedResult.rows.concat(untrackedRows);
|
|
||||||
log.debugf(
|
|
||||||
"Token balances",
|
|
||||||
addr.address,
|
|
||||||
addr.tokenBalances.length,
|
|
||||||
"tokens",
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -440,21 +290,6 @@ async function refreshBalances(
|
|||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
log.debugf("refreshBalances done");
|
log.debugf("refreshBalances done");
|
||||||
return [...trackedSet].filter((lower) => !unread.has(lower));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drops the balance of each token not in `trackedTokens` from every address.
|
|
||||||
// Callers run it while "Use Blockscout" is off, since only Blockscout reports
|
|
||||||
// those tokens.
|
|
||||||
function dropUntrackedTokens(wallets, trackedTokens) {
|
|
||||||
const tracked = new Set(trackedTokens.map((t) => t.address.toLowerCase()));
|
|
||||||
for (const wallet of wallets) {
|
|
||||||
for (const addr of wallet.addresses || []) {
|
|
||||||
addr.tokenBalances = (addr.tokenBalances || []).filter((row) =>
|
|
||||||
tracked.has(String(row.address).toLowerCase()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up token metadata from its contract.
|
// Look up token metadata from its contract.
|
||||||
@@ -575,7 +410,6 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
fetchTokenBalances,
|
fetchTokenBalances,
|
||||||
refreshBalances,
|
refreshBalances,
|
||||||
dropUntrackedTokens,
|
|
||||||
lookupTokenInfo,
|
lookupTokenInfo,
|
||||||
getProvider,
|
getProvider,
|
||||||
scanForAddresses,
|
scanForAddresses,
|
||||||
|
|||||||
+5
-148
@@ -31,11 +31,6 @@
|
|||||||
// Everything is resolved on use rather than captured at module load. The MV3
|
// Everything is resolved on use rather than captured at module load. The MV3
|
||||||
// service worker is torn down and re-evaluated repeatedly, and the unit
|
// service worker is torn down and re-evaluated repeatedly, and the unit
|
||||||
// suite installs its stubs on `global.chrome` around a require().
|
// suite installs its stubs on `global.chrome` around a require().
|
||||||
//
|
|
||||||
// The module also holds withLock(), a wrapper around the browser's
|
|
||||||
// `navigator.locks`, which is in neither namespace. It sits beside
|
|
||||||
// storageGet() and storageSet() because every write of the stored record
|
|
||||||
// takes it.
|
|
||||||
|
|
||||||
// The extension API namespace, preferring `browser.*` where it exists.
|
// The extension API namespace, preferring `browser.*` where it exists.
|
||||||
//
|
//
|
||||||
@@ -150,14 +145,6 @@ function sendMessage(message) {
|
|||||||
return invoke(runtimeApi(), "sendMessage", message);
|
return invoke(runtimeApi(), "sendMessage", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Promise<Object>} the operating system and processor the browser
|
|
||||||
* runs on.
|
|
||||||
*/
|
|
||||||
function getPlatformInfo() {
|
|
||||||
return invoke(runtimeApi(), "getPlatformInfo");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message nobody is expected to answer, and swallow the fact that
|
* Send a message nobody is expected to answer, and swallow the fact that
|
||||||
* nobody did.
|
* nobody did.
|
||||||
@@ -179,9 +166,9 @@ function notify(message) {
|
|||||||
// default: resolving {} would make an existing wallet read back as no wallet,
|
// default: resolving {} would make an existing wallet read back as no wallet,
|
||||||
// and resolving a no-op write would discard the user's state with nothing
|
// and resolving a no-op write would discard the user's state with nothing
|
||||||
// logged. A caller that wants to degrade takes storageLocal() directly.
|
// logged. A caller that wants to degrade takes storageLocal() directly.
|
||||||
function storageUnavailable(area, method) {
|
function storageUnavailable(method) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error("extension storage." + area + " is not available: " + method),
|
new Error("extension storage.local is not available: " + method),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +179,7 @@ function storageUnavailable(area, method) {
|
|||||||
*/
|
*/
|
||||||
function storageGet(keys) {
|
function storageGet(keys) {
|
||||||
const storage = storageLocal();
|
const storage = storageLocal();
|
||||||
if (!storage) return storageUnavailable("local", "get");
|
if (!storage) return storageUnavailable("get");
|
||||||
return Promise.resolve(storage.get(keys));
|
return Promise.resolve(storage.get(keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +190,7 @@ function storageGet(keys) {
|
|||||||
*/
|
*/
|
||||||
function storageSet(items) {
|
function storageSet(items) {
|
||||||
const storage = storageLocal();
|
const storage = storageLocal();
|
||||||
if (!storage) return storageUnavailable("local", "set");
|
if (!storage) return storageUnavailable("set");
|
||||||
return Promise.resolve(storage.set(items));
|
return Promise.resolve(storage.set(items));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,95 +207,7 @@ function storageSet(items) {
|
|||||||
*/
|
*/
|
||||||
function storageRemove(keys) {
|
function storageRemove(keys) {
|
||||||
const storage = storageLocal();
|
const storage = storageLocal();
|
||||||
if (!storage) return storageUnavailable("local", "remove");
|
if (!storage) return storageUnavailable("remove");
|
||||||
return Promise.resolve(storage.remove(keys));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call `listener` after every write to `storage.local`, whether this page,
|
|
||||||
* another page or the background made it, with what the write changed, as the
|
|
||||||
* browser reports it: `{ [key]: { oldValue, newValue } }`.
|
|
||||||
*
|
|
||||||
* @param {function(Object): void} listener
|
|
||||||
*/
|
|
||||||
function onStorageChanged(listener) {
|
|
||||||
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
|
|
||||||
if (areaName === "local") listener(changes);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run `fn` while holding the browser's lock called `name`
|
|
||||||
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
|
|
||||||
* and its background share these locks, except a page in a Firefox private
|
|
||||||
* window (README, "Stored state and its version"), so while one of them holds
|
|
||||||
* `name` another asking for it waits, in the order they asked. The lock is let
|
|
||||||
* go when `fn` settles, whether it resolved or rejected.
|
|
||||||
*
|
|
||||||
* Every write of the stored record holds the lock named after its key,
|
|
||||||
* "autistmask", from its read to its write: saveState() in
|
|
||||||
* src/shared/state.js, updateState() in src/background/state.js, and the
|
|
||||||
* reset in src/popup/views/stateRecovery.js.
|
|
||||||
*
|
|
||||||
* @param {string} name
|
|
||||||
* @param {function(): Promise<*>} fn
|
|
||||||
* @returns {Promise<*>} what `fn` resolved with.
|
|
||||||
* @throws rejects, without running `fn`, where `navigator.locks` is absent.
|
|
||||||
*/
|
|
||||||
function withLock(name, fn) {
|
|
||||||
if (typeof navigator === "undefined" || !navigator.locks) {
|
|
||||||
return Promise.reject(
|
|
||||||
new Error("navigator.locks is not available in this context"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return navigator.locks.request(name, () => fn());
|
|
||||||
}
|
|
||||||
|
|
||||||
// `storage.session`, the browser's session storage: what is kept there lasts
|
|
||||||
// until the browser closes. It survives the background worker being stopped
|
|
||||||
// and started again, which module-level variables do not (README, Background
|
|
||||||
// scheduling). The background keeps the connections allowed without
|
|
||||||
// "Remember" there; see src/background/index.js. Firefox has it from version
|
|
||||||
// 115, the minimum manifest/firefox.json declares.
|
|
||||||
//
|
|
||||||
// The three below reject where it is absent, as the three above do where
|
|
||||||
// `storage.local` is: a write that silently did nothing would tell a site it
|
|
||||||
// is connected and then refuse its next request.
|
|
||||||
function storageSession() {
|
|
||||||
const storage = namespaceMember("storage");
|
|
||||||
return (storage && storage.session) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string|string[]|null} keys null for every item.
|
|
||||||
* @returns {Promise<Object>} the stored items.
|
|
||||||
* @throws rejects where `storage.session` is absent.
|
|
||||||
*/
|
|
||||||
function sessionStorageGet(keys) {
|
|
||||||
const storage = storageSession();
|
|
||||||
if (!storage) return storageUnavailable("session", "get");
|
|
||||||
return Promise.resolve(storage.get(keys));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} items
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
* @throws rejects where `storage.session` is absent.
|
|
||||||
*/
|
|
||||||
function sessionStorageSet(items) {
|
|
||||||
const storage = storageSession();
|
|
||||||
if (!storage) return storageUnavailable("session", "set");
|
|
||||||
return Promise.resolve(storage.set(items));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string|string[]} keys
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
* @throws rejects where `storage.session` is absent.
|
|
||||||
*/
|
|
||||||
function sessionStorageRemove(keys) {
|
|
||||||
const storage = storageSession();
|
|
||||||
if (!storage) return storageUnavailable("session", "remove");
|
|
||||||
return Promise.resolve(storage.remove(keys));
|
return Promise.resolve(storage.remove(keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,22 +235,6 @@ function tabsSendMessage(tabId, message) {
|
|||||||
return invoke(tabsApi(), "sendMessage", tabId, message);
|
return invoke(tabsApi(), "sendMessage", tabId, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Promise<Object|undefined>} the tab the calling page is in, or
|
|
||||||
* undefined for a page not in a tab, such as the toolbar popup.
|
|
||||||
*/
|
|
||||||
function tabsGetCurrent() {
|
|
||||||
return invoke(tabsApi(), "getCurrent");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} tabId
|
|
||||||
* @returns {Promise<number>} that tab's zoom: 1.5 at 150%.
|
|
||||||
*/
|
|
||||||
function tabsGetZoom(tabId) {
|
|
||||||
return invoke(tabsApi(), "getZoom", tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} createData
|
* @param {Object} createData
|
||||||
* @returns {Promise<Object>} the created window.
|
* @returns {Promise<Object>} the created window.
|
||||||
@@ -367,22 +250,6 @@ function windowsGetLastFocused() {
|
|||||||
return invoke(windowsApi(), "getLastFocused");
|
return invoke(windowsApi(), "getLastFocused");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Promise<Object>} the window the calling page is in.
|
|
||||||
*/
|
|
||||||
function windowsGetCurrent() {
|
|
||||||
return invoke(windowsApi(), "getCurrent");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} windowId
|
|
||||||
* @param {Object} updateInfo
|
|
||||||
* @returns {Promise<Object>} the updated window.
|
|
||||||
*/
|
|
||||||
function windowsUpdate(windowId, updateInfo) {
|
|
||||||
return invoke(windowsApi(), "update", windowId, updateInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} windowId
|
* @param {number} windowId
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -395,29 +262,19 @@ module.exports = {
|
|||||||
actionApi,
|
actionApi,
|
||||||
alarmsApi,
|
alarmsApi,
|
||||||
extensionApi,
|
extensionApi,
|
||||||
getPlatformInfo,
|
|
||||||
hasBrowserNamespace,
|
hasBrowserNamespace,
|
||||||
notify,
|
notify,
|
||||||
onStorageChanged,
|
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sessionStorageGet,
|
|
||||||
sessionStorageRemove,
|
|
||||||
sessionStorageSet,
|
|
||||||
storageGet,
|
storageGet,
|
||||||
storageLocal,
|
storageLocal,
|
||||||
storageRemove,
|
storageRemove,
|
||||||
storageSet,
|
storageSet,
|
||||||
tabsApi,
|
tabsApi,
|
||||||
tabsGetCurrent,
|
|
||||||
tabsGetZoom,
|
|
||||||
tabsQuery,
|
tabsQuery,
|
||||||
tabsSendMessage,
|
tabsSendMessage,
|
||||||
windowsApi,
|
windowsApi,
|
||||||
windowsCreate,
|
windowsCreate,
|
||||||
windowsGetCurrent,
|
|
||||||
windowsGetLastFocused,
|
windowsGetLastFocused,
|
||||||
windowsRemove,
|
windowsRemove,
|
||||||
windowsUpdate,
|
|
||||||
withLock,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-37
@@ -1,18 +1,8 @@
|
|||||||
// The popup's network switch.
|
// Consolidated chain-switch handler for the popup.
|
||||||
//
|
//
|
||||||
// A network switch reaches the popup's `state` in one of two ways:
|
// Every state change required when the active network changes is
|
||||||
//
|
// performed here so that callers (settings UI, future chain additions) all go
|
||||||
// - onChainSwitch() below makes the switch the user picks in Settings, with
|
// through a single code path.
|
||||||
// applyChainSwitchFields() (chainSwitchFields.js).
|
|
||||||
// - followStoredSettings() in src/popup/index.js takes a switch the background
|
|
||||||
// or another popup page saved, from the stored record, with
|
|
||||||
// takeStoredNetwork() (state.js), which copies it with
|
|
||||||
// copyChainSwitchFields() (chainSwitchFields.js).
|
|
||||||
//
|
|
||||||
// Both change `state` inside a save, holding the lock, and both reset the
|
|
||||||
// popup's own caches through clearPopupCaches(), so a reset added there
|
|
||||||
// reaches both. A field applyChainSwitchFields() sets has to be copied by
|
|
||||||
// copyChainSwitchFields() too.
|
|
||||||
//
|
//
|
||||||
// Adding a new chain (e.g. ETC) requires only a new entry in
|
// Adding a new chain (e.g. ETC) requires only a new entry in
|
||||||
// networks.js — no per-caller wiring is needed.
|
// networks.js — no per-caller wiring is needed.
|
||||||
@@ -30,32 +20,22 @@
|
|||||||
const { applyChainSwitchFields } = require("./chainSwitchFields");
|
const { applyChainSwitchFields } = require("./chainSwitchFields");
|
||||||
const { clearPrices } = require("./prices");
|
const { clearPrices } = require("./prices");
|
||||||
|
|
||||||
// The popup's own caches a network switch resets. Prices are chain-specific
|
|
||||||
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
|
|
||||||
// and per bundle, so this is the popup's own cache — the only context that
|
|
||||||
// ever fills it.
|
|
||||||
function clearPopupCaches() {
|
|
||||||
clearPrices();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch the active chain and reset all chain-specific cached state.
|
// Switch the active chain and reset all chain-specific cached state.
|
||||||
// Returns the network configuration object for the new chain, or null when
|
// Returns the network configuration object for the new chain.
|
||||||
// `state` already has that network once the save holds the lock, and nothing
|
|
||||||
// is switched.
|
|
||||||
//
|
|
||||||
// `state` is switched inside the save that stores the switch, holding the
|
|
||||||
// lock, so it is never switched while a follow of a switch saved elsewhere
|
|
||||||
// waits for the lock (followStoredSettings() in src/popup/index.js): that
|
|
||||||
// follow runs first, and this switch is made after it.
|
|
||||||
async function onChainSwitch(newNetworkId) {
|
async function onChainSwitch(newNetworkId) {
|
||||||
const { state, saveState } = require("./state");
|
const { state, saveState } = require("./state");
|
||||||
let net = null;
|
|
||||||
await saveState(() => {
|
const net = applyChainSwitchFields(state, newNetworkId);
|
||||||
if (state.networkId === newNetworkId) return false;
|
|
||||||
net = applyChainSwitchFields(state, newNetworkId);
|
// --- price cache ---
|
||||||
clearPopupCaches();
|
// Prices are chain-specific (testnet tokens are worthless,
|
||||||
});
|
// ETC has different pricing, etc.). In-memory and per bundle, so this is
|
||||||
|
// the popup's own cache — the only context that ever fills it.
|
||||||
|
clearPrices();
|
||||||
|
|
||||||
|
await saveState();
|
||||||
|
|
||||||
return net;
|
return net;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { onChainSwitch, clearPopupCaches };
|
module.exports = { onChainSwitch };
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// the singleton and saves; the background applies it to the detached record of
|
// the singleton and saves; the background applies it to the detached record of
|
||||||
// its own read-modify-write (src/background/state.js).
|
// its own read-modify-write (src/background/state.js).
|
||||||
//
|
//
|
||||||
// Everything here is synchronous and touches nothing but the objects it is
|
// Everything here is synchronous and touches nothing but the object it is
|
||||||
// given: no storage, no caches, no imports beyond the network table. That is
|
// given: no storage, no caches, no imports beyond the network table. That is
|
||||||
// what makes it usable on a record that has been read fresh from storage
|
// what makes it usable on a record that has been read fresh from storage
|
||||||
// microseconds earlier and is about to be written back.
|
// microseconds earlier and is about to be written back.
|
||||||
@@ -38,16 +38,6 @@ function applyChainSwitchFields(s, newNetworkId) {
|
|||||||
blockscoutUrl: s.blockscoutUrl,
|
blockscoutUrl: s.blockscoutUrl,
|
||||||
};
|
};
|
||||||
const remembered = s.networkEndpoints[net.id] || {};
|
const remembered = s.networkEndpoints[net.id] || {};
|
||||||
|
|
||||||
// Tracked tokens are kept per network the same way, because a contract
|
|
||||||
// address names a token on one network only. s.trackedTokens stays the
|
|
||||||
// active network's list, so nothing that reads it changes: the outgoing
|
|
||||||
// list is put away under its network, and the incoming network's list, or
|
|
||||||
// an empty one, takes its place.
|
|
||||||
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
|
|
||||||
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
|
|
||||||
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
|
|
||||||
|
|
||||||
s.networkId = net.id;
|
s.networkId = net.id;
|
||||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||||
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
||||||
@@ -76,33 +66,4 @@ function applyChainSwitchFields(s, newNetworkId) {
|
|||||||
return net;
|
return net;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
|
module.exports = { applyChainSwitchFields };
|
||||||
// the popup takes a switch the background saved from the stored record this
|
|
||||||
// way, rather than performing it again (takeStoredNetwork() in
|
|
||||||
// src/shared/state.js). Balances are taken by address, and an address
|
|
||||||
// `switched` does not have keeps none.
|
|
||||||
function copyChainSwitchFields(s, switched) {
|
|
||||||
s.networkEndpoints = switched.networkEndpoints;
|
|
||||||
s.networkTrackedTokens = switched.networkTrackedTokens;
|
|
||||||
s.trackedTokens = switched.trackedTokens;
|
|
||||||
s.networkId = switched.networkId;
|
|
||||||
s.rpcUrl = switched.rpcUrl;
|
|
||||||
s.blockscoutUrl = switched.blockscoutUrl;
|
|
||||||
s.lastBalanceRefresh = switched.lastBalanceRefresh;
|
|
||||||
s.tokenHolderCache = switched.tokenHolderCache;
|
|
||||||
s.fraudContracts = switched.fraudContracts;
|
|
||||||
|
|
||||||
const stored = new Map();
|
|
||||||
for (const wallet of switched.wallets) {
|
|
||||||
for (const addr of wallet.addresses) stored.set(addr.address, addr);
|
|
||||||
}
|
|
||||||
for (const wallet of s.wallets) {
|
|
||||||
for (const addr of wallet.addresses) {
|
|
||||||
const got = stored.get(addr.address);
|
|
||||||
addr.balance = got ? got.balance : "0";
|
|
||||||
addr.tokenBalances = got ? got.tokenBalances : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
// Extension users make the requests directly to Etherscan — no proxy needed.
|
// Extension users make the requests directly to Etherscan — no proxy needed.
|
||||||
// This is a best-effort enrichment: network failures return null silently.
|
// This is a best-effort enrichment: network failures return null silently.
|
||||||
|
|
||||||
const { networkByChainId } = require("./networks");
|
|
||||||
const { storedSettingIsOn } = require("./storedSettings");
|
|
||||||
|
|
||||||
// Patterns in the page title that indicate a flagged address.
|
// Patterns in the page title that indicate a flagged address.
|
||||||
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
|
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
|
||||||
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
|
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
|
||||||
@@ -75,36 +72,23 @@ function parseEtherscanPage(html) {
|
|||||||
* Returns a warning object if the address is flagged, or null.
|
* Returns a warning object if the address is flagged, or null.
|
||||||
* Network failures return null silently (best-effort check).
|
* Network failures return null silently (best-effort check).
|
||||||
*
|
*
|
||||||
* Uses the explorer of the network with `chainId`, or of the active network
|
* Uses the current network's explorer URL so the lookup works on both
|
||||||
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
|
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
|
||||||
* Sepolia.
|
|
||||||
*
|
|
||||||
* While the stored "Check recipients on Etherscan" is off, Etherscan is not
|
|
||||||
* asked. An answer that lands after the setting went off is dropped
|
|
||||||
* (src/shared/storedSettings.js). Either way the answer is null.
|
|
||||||
*
|
*
|
||||||
* @param {string} address - Ethereum address to check.
|
* @param {string} address - Ethereum address to check.
|
||||||
* @param {string} [chainId] - Chain id of the network to look it up on.
|
|
||||||
* @returns {Promise<{type: string, message: string, severity: string}|null>}
|
* @returns {Promise<{type: string, message: string, severity: string}|null>}
|
||||||
*/
|
*/
|
||||||
async function checkEtherscanLabel(address, chainId) {
|
async function checkEtherscanLabel(address) {
|
||||||
try {
|
try {
|
||||||
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Lazy require to avoid pulling in chrome.storage at module scope
|
// Lazy require to avoid pulling in chrome.storage at module scope
|
||||||
// (which breaks unit tests that only exercise parseEtherscanPage).
|
// (which breaks unit tests that only exercise parseEtherscanPage).
|
||||||
const { currentNetwork } = require("./state");
|
const { currentNetwork } = require("./state");
|
||||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
const etherscanBase = currentNetwork().explorerUrl + "/address/";
|
||||||
const etherscanBase = network.explorerUrl + "/address/";
|
|
||||||
const resp = await fetch(etherscanBase + address, {
|
const resp = await fetch(etherscanBase + address, {
|
||||||
headers: { Accept: "text/html" },
|
headers: { Accept: "text/html" },
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const result = parseEtherscanPage(html);
|
const result = parseEtherscanPage(html);
|
||||||
if (result.isPhishing) {
|
if (result.isPhishing) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ const DEFAULT_STATE = {
|
|||||||
hasWallet: false,
|
hasWallet: false,
|
||||||
wallets: [],
|
wallets: [],
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
|
|
||||||
// trackedTokens above is the active network's list; this is what the
|
|
||||||
// others are restored from when the active network changes. See
|
|
||||||
// applyChainSwitchFields().
|
|
||||||
networkTrackedTokens: {},
|
|
||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: DEFAULT_RPC_URL,
|
rpcUrl: DEFAULT_RPC_URL,
|
||||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||||
@@ -48,17 +43,6 @@ const DEFAULT_STATE = {
|
|||||||
hideDustTransactions: true,
|
hideDustTransactions: true,
|
||||||
dustThresholdGwei: 100000,
|
dustThresholdGwei: 100000,
|
||||||
utcTimestamps: false,
|
utcTimestamps: false,
|
||||||
// "Use Blockscout" and "Check recipients on Etherscan" in Settings. Each
|
|
||||||
// setting applies to every network. Both are off until the user turns them
|
|
||||||
// on. With "Use Blockscout" on, Blockscout is sent the user's addresses and
|
|
||||||
// the hash of each transaction opened. With "Check recipients on
|
|
||||||
// Etherscan" on, Etherscan is sent the recipient's address and nothing
|
|
||||||
// else.
|
|
||||||
useBlockscout: false,
|
|
||||||
checkRecipientsOnEtherscan: false,
|
|
||||||
// "Allow weak passwords" in Settings. While it is off, AddWallet refuses a
|
|
||||||
// password shorter than 12 characters.
|
|
||||||
allowWeakPasswords: false,
|
|
||||||
fraudContracts: [],
|
fraudContracts: [],
|
||||||
tokenHolderCache: {},
|
tokenHolderCache: {},
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -305,20 +289,6 @@ function normalizePersisted(saved) {
|
|||||||
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
||||||
// value and survives.
|
// value and survives.
|
||||||
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
||||||
// Each remembered list is floored like trackedTokens, because
|
|
||||||
// applyChainSwitchFields() puts it straight back onto trackedTokens when
|
|
||||||
// its network becomes the active one. A key that is not a network in
|
|
||||||
// networks.js is dropped, which also keeps an own "__proto__" key from
|
|
||||||
// reaching the plain assignment below.
|
|
||||||
out.networkTrackedTokens = {};
|
|
||||||
if (isRecord(saved.networkTrackedTokens)) {
|
|
||||||
for (const netId of Object.keys(saved.networkTrackedTokens)) {
|
|
||||||
if (!isKnownNetworkId(netId)) continue;
|
|
||||||
out.networkTrackedTokens[netId] = structuredClone(
|
|
||||||
tokenRefs(saved.networkTrackedTokens[netId]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||||
// floor under it. networkId is an object KEY into networkEndpoints below,
|
// floor under it. networkId is an object KEY into networkEndpoints below,
|
||||||
// so a value that is not a network in networks.js must never get that far
|
// so a value that is not a network in networks.js must never get that far
|
||||||
@@ -417,13 +387,6 @@ function normalizePersisted(saved) {
|
|||||||
: 100000;
|
: 100000;
|
||||||
out.utcTimestamps =
|
out.utcTimestamps =
|
||||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||||
// Anything but true is off: each setting lets a request reach another
|
|
||||||
// service.
|
|
||||||
out.useBlockscout = saved.useBlockscout === true;
|
|
||||||
out.checkRecipientsOnEtherscan = saved.checkRecipientsOnEtherscan === true;
|
|
||||||
// Anything but true is off: the setting lets AddWallet accept a short
|
|
||||||
// password.
|
|
||||||
out.allowWeakPasswords = saved.allowWeakPasswords === true;
|
|
||||||
// A list of contract addresses, floored the same way: send.js builds its
|
// A list of contract addresses, floored the same way: send.js builds its
|
||||||
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
|
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
|
||||||
// and filterTransactions() maps the same list through normalizeAddress(),
|
// and filterTransactions() maps the same list through normalizeAddress(),
|
||||||
|
|||||||
@@ -110,14 +110,11 @@ function hashListed(hash) {
|
|||||||
* Generate hostname variants for subdomain matching.
|
* Generate hostname variants for subdomain matching.
|
||||||
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
|
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
|
||||||
*
|
*
|
||||||
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
|
|
||||||
* "evil.com", and the browser loads the same site from either.
|
|
||||||
*
|
|
||||||
* @param {string} hostname
|
* @param {string} hostname
|
||||||
* @returns {string[]}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function hostnameVariants(hostname) {
|
function hostnameVariants(hostname) {
|
||||||
const h = hostname.toLowerCase().replace(/\.$/, "");
|
const h = hostname.toLowerCase();
|
||||||
const variants = [h];
|
const variants = [h];
|
||||||
const parts = h.split(".");
|
const parts = h.split(".");
|
||||||
// Parent domains: a.b.c.d -> b.c.d, c.d
|
// Parent domains: a.b.c.d -> b.c.d, c.d
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
const { getTopTokenPrices } = require("./tokenList");
|
const { getTopTokenPrices } = require("./tokenList");
|
||||||
const { isBelowOneMillionth } = require("./amountDisplay");
|
const { isBelowOneMillionth } = require("./amountDisplay");
|
||||||
const { networkByChainId } = require("./networks");
|
|
||||||
|
|
||||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
||||||
|
|
||||||
@@ -37,13 +36,10 @@ function clearPrices() {
|
|||||||
lastFetchedAt = 0;
|
lastFetchedAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the USD price for a symbol on the network with `chainId`, or on the
|
// Return the USD price for a symbol, or null on testnet / unknown.
|
||||||
// active network when no chain id is given; null on testnet, on a chain id
|
function getPrice(symbol) {
|
||||||
// that names no network, or for a symbol with no price.
|
|
||||||
function getPrice(symbol, chainId) {
|
|
||||||
const { currentNetwork } = require("./state");
|
const { currentNetwork } = require("./state");
|
||||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
if (currentNetwork().isTestnet) return null;
|
||||||
if (!network || network.isTestnet) return null;
|
|
||||||
return prices[symbol] || null;
|
return prices[symbol] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-91
@@ -23,7 +23,6 @@
|
|||||||
// guarantee.
|
// guarantee.
|
||||||
|
|
||||||
const { networkById } = require("./networks");
|
const { networkById } = require("./networks");
|
||||||
const { copyChainSwitchFields } = require("./chainSwitchFields");
|
|
||||||
const {
|
const {
|
||||||
DEFAULT_STATE,
|
DEFAULT_STATE,
|
||||||
PERSISTED_FIELDS,
|
PERSISTED_FIELDS,
|
||||||
@@ -36,18 +35,16 @@ const {
|
|||||||
migrationNeeded,
|
migrationNeeded,
|
||||||
} = require("./stateSchema");
|
} = require("./stateSchema");
|
||||||
|
|
||||||
const { storageGet, storageSet, withLock } = require("./browserApi");
|
const { storageGet, storageSet } = require("./browserApi");
|
||||||
const { log } = require("./log");
|
const { log } = require("./log");
|
||||||
|
|
||||||
// The live record the proxy below guards. Everything inside this module reads
|
// The live record the proxy below guards. Everything inside this module reads
|
||||||
// and writes THIS object, never the proxy: the guard is for callers.
|
// and writes THIS object, never the proxy: the guard is for callers.
|
||||||
const rawState = {
|
const rawState = {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
// Their own objects, not the ones DEFAULT_STATE holds:
|
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
||||||
// applyChainSwitchFields() mutates these maps in place, and a spread copies
|
// mutates this map in place, and a spread copies the reference.
|
||||||
// the reference.
|
|
||||||
networkEndpoints: {},
|
networkEndpoints: {},
|
||||||
networkTrackedTokens: {},
|
|
||||||
currentView: null,
|
currentView: null,
|
||||||
selectedWallet: null,
|
selectedWallet: null,
|
||||||
selectedAddress: null,
|
selectedAddress: null,
|
||||||
@@ -126,11 +123,8 @@ function currentNetwork() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The persisted fields as this page held them when its last loadState()
|
// The persisted fields as this page held them when its last loadState()
|
||||||
// finished, or when its last successful saveState() began, with any network
|
// finished, or when its last successful saveState() began. saveState() diffs
|
||||||
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
|
// the live state against this to find only the fields THIS page changed since.
|
||||||
// `baseline` also lacks any `balanceNotFetched` field that a balance refresh has
|
|
||||||
// deleted since (clearBalanceNotFetched()). saveState() diffs the live state
|
|
||||||
// against this to find only the fields THIS page changed since.
|
|
||||||
//
|
//
|
||||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||||
@@ -444,14 +438,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
//
|
//
|
||||||
// Every other persisted field stays a whole-field diff:
|
// Every other persisted field stays a whole-field diff:
|
||||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||||
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
|
||||||
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
// the ones above, but nothing in src/ ever writes an entry into it — it is
|
||||||
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
|
||||||
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||||
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
|
// this page's own UI scratch space, not data another page has any reason to
|
||||||
// mutation for a whole-field diff to collide with; `viewData` is this page's
|
// share membership of.
|
||||||
// own UI scratch space, not data another page has any reason to share
|
|
||||||
// membership of.
|
|
||||||
//
|
//
|
||||||
// This does not make two pages that both change the SAME leaf concurrently
|
// 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
|
// safe: last write wins on that one leaf, same as before. What it removes
|
||||||
@@ -462,20 +454,20 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// This page's own live `state` is deliberately NOT rehydrated from a field
|
// This page's own live `state` is deliberately NOT rehydrated from a field
|
||||||
// another page changed — only the record written to storage is merged.
|
// another page changed — only the record written to storage is merged.
|
||||||
// showView() fires saveState() on every navigation without awaiting it,
|
// showView() fires saveState() on every navigation without awaiting it,
|
||||||
// which is why saves from one page wait for each other (see saveState()), and
|
// which is what makes the queue above necessary in the first place, and a
|
||||||
// a save that is slow to come back has no way to tell whether the field it
|
// save that is slow to come back has no way to tell whether the field it
|
||||||
// is about to hand back is still the current answer or has since been
|
// is about to hand back is still the current answer or has since been
|
||||||
// overtaken by something this very page did in the meantime; writing it
|
// overtaken by something this very page did in the meantime; writing it
|
||||||
// into `state` regardless reintroduced exactly the clobber this function
|
// into `state` regardless reintroduced exactly the clobber this function
|
||||||
// exists to remove, just delayed and confined to one page instead of two
|
// exists to remove, just delayed and confined to one page instead of two
|
||||||
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
||||||
// does not own goes on being whatever its last loadState() saw, same as
|
// does not own goes on being whatever its last loadState() saw, same as
|
||||||
// before this fix; only the persisted record is guaranteed current. Three
|
// before this fix; only the persisted record is guaranteed current.
|
||||||
// fields are the exceptions: the network, "Use Blockscout" and "Check
|
async function saveStateOnce() {
|
||||||
// recipients on Etherscan". Through `beforeWrite`, the popup takes a change to
|
// A copy, so what this save compares and writes is the page's state as it
|
||||||
// any of them that another context saved (followStoredSettings() in
|
// stood when the save began. A change made while it waits on storage is
|
||||||
// src/popup/index.js).
|
// left for the next save, which compares against this copy.
|
||||||
async function saveStateOnce(beforeWrite) {
|
const current = structuredClone(snapshotPersisted());
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
// The record in storage right now is about to be merged into and written
|
// The record in storage right now is about to be merged into and written
|
||||||
// back, so it is validated exactly like a load validates it. Without this,
|
// back, so it is validated exactly like a load validates it. Without this,
|
||||||
@@ -489,12 +481,6 @@ async function saveStateOnce(beforeWrite) {
|
|||||||
// come from storage in its loaded (self-healed) shape. See
|
// come from storage in its loaded (self-healed) shape. See
|
||||||
// normalizePersisted() in persistedState.js.
|
// normalizePersisted() in persistedState.js.
|
||||||
const fresh = normalizePersisted(result.autistmask);
|
const fresh = normalizePersisted(result.autistmask);
|
||||||
if (beforeWrite && beforeWrite(fresh) === false) return;
|
|
||||||
|
|
||||||
// A copy, so what this save compares and writes is the page's state as it
|
|
||||||
// stood once the stored record was read. A change made while it waits on
|
|
||||||
// the write is left for the next save, which compares against this copy.
|
|
||||||
const current = structuredClone(snapshotPersisted());
|
|
||||||
|
|
||||||
const merged = { ...fresh };
|
const merged = { ...fresh };
|
||||||
for (const key of PERSISTED_FIELDS) {
|
for (const key of PERSISTED_FIELDS) {
|
||||||
@@ -539,21 +525,35 @@ async function saveStateOnce(beforeWrite) {
|
|||||||
rawState.hasWallet = rawState.wallets.length > 0;
|
rawState.hasWallet = rawState.wallets.length > 0;
|
||||||
|
|
||||||
// What this save compared and wrote, not the page's state now: a change
|
// What this save compared and wrote, not the page's state now: a change
|
||||||
// made during the save must still differ from the baseline, or the next
|
// made during the save must still differ from the baseline, or the save
|
||||||
// save finds nothing to store
|
// queued after it finds nothing to store
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
|
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
|
||||||
baseline = current;
|
baseline = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showView() calls saveState() on every navigation without awaiting it, so
|
||||||
|
// two saves from the SAME page can be in flight at once — e.g. a screen
|
||||||
|
// shown, then immediately replaced before the first save's storageGet()
|
||||||
|
// round trip has come back. Left concurrent, the first save's turn would
|
||||||
|
// finish after the second's live-state mutation and then re-hydrate `state`
|
||||||
|
// from what IT read, stomping the second, later change back to a stale
|
||||||
|
// value — the same clobber this function exists to prevent, just between
|
||||||
|
// two saves on one page instead of two pages. Queuing makes every save's
|
||||||
|
// snapshot-diff-write-rehydrate run start to finish before the next one
|
||||||
|
// begins, so each one only ever sees the true live state at its turn.
|
||||||
|
let saveQueue = Promise.resolve();
|
||||||
|
|
||||||
// Where a failed save is REPORTED, set once by the context that has a screen
|
// Where a failed save is REPORTED, set once by the context that has a screen
|
||||||
// to say it on (src/popup/index.js).
|
// to say it on (src/popup/index.js).
|
||||||
//
|
//
|
||||||
// A save that fails must not fail silently. showView() fires saveState() on
|
// A save that fails must not fail silently. showView() fires saveState() on
|
||||||
// every navigation without awaiting it, and `await saveState()` inside an
|
// every navigation without awaiting it, and the queue below has to attach a
|
||||||
// unguarded event handler is no better: either way, without this, a failure
|
// rejection handler to keep advancing — so a failing save was swallowed
|
||||||
// would reach only the console, where the user never looks, and the wallet
|
// entirely: no throw, no message, nothing on screen. The wallet kept running
|
||||||
// would keep running against storage that rejects every write. That is the
|
// against storage that was rejecting every write, which is the data-loss half
|
||||||
// data-loss half of https://git.eeqj.de/sneak/AutistMask/issues/362.
|
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
|
||||||
|
// no better off: `await saveState()` inside an unguarded event handler surfaces
|
||||||
|
// in the console and nowhere the user looks.
|
||||||
//
|
//
|
||||||
// This is the "tell the user" half; the other half is the floor in
|
// This is the "tell the user" half; the other half is the floor in
|
||||||
// normalizePersisted(), which stops the malformed-record cause from arising in
|
// normalizePersisted(), which stops the malformed-record cause from arising in
|
||||||
@@ -579,23 +579,11 @@ function reportSaveFailure(err) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each save runs start to finish holding the lock every write of the stored
|
function saveState() {
|
||||||
// record takes (withLock() in src/shared/browserApi.js), so no write by
|
const turn = saveQueue.then(saveStateOnce);
|
||||||
// another page or the background lands between its read and its write and is
|
// The queue must advance even when a save rejects, or every save after
|
||||||
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641).
|
// it queues behind a promise that never settles.
|
||||||
//
|
saveQueue = turn.catch(() => {});
|
||||||
// The same lock orders this page's own saves. showView() calls saveState() on
|
|
||||||
// every navigation without awaiting it, so two saves from one page can be
|
|
||||||
// asked for at once; they take the lock in the order they were asked, and each
|
|
||||||
// compares the page's state, as it stands once the stored record has been read
|
|
||||||
// and `beforeWrite` has run, against the baseline the save before it left.
|
|
||||||
//
|
|
||||||
// `beforeWrite`, when given, is called inside the lock with the stored record
|
|
||||||
// as just read, before this page's state is compared against it. It may change
|
|
||||||
// `state`, and this save writes that change. When it returns false, nothing is
|
|
||||||
// written; any other return lets the save go ahead.
|
|
||||||
function saveState(beforeWrite) {
|
|
||||||
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
|
||||||
// Every failed save is reported, whether or not the caller awaited this
|
// Every failed save is reported, whether or not the caller awaited this
|
||||||
// one. The returned promise still rejects, so a caller that DOES await
|
// one. The returned promise still rejects, so a caller that DOES await
|
||||||
// keeps its own error handling.
|
// keeps its own error handling.
|
||||||
@@ -603,38 +591,6 @@ function saveState(beforeWrite) {
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give this page the network switch held by `stored`, a record a save of this
|
|
||||||
// page has just read (followStoredSettings() in src/popup/index.js calls this
|
|
||||||
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
|
|
||||||
// with a whole record. The switch is then not this page's own change, even when
|
|
||||||
// that save fails to write: a later switch back to the previous network still
|
|
||||||
// differs from `baseline`, and is stored.
|
|
||||||
function takeStoredNetwork(stored) {
|
|
||||||
copyChainSwitchFields(rawState, stored);
|
|
||||||
copyChainSwitchFields(baseline, structuredClone(stored));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes `balanceNotFetched` from each tracked token whose lowercased
|
|
||||||
// contract address is in `addresses`. The field goes from `stored`, from
|
|
||||||
// `state` and from `baseline` alike. `stored` is the record a save of this
|
|
||||||
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
|
|
||||||
// from `beforeWrite`). So the deletion is not a change this page made to its
|
|
||||||
// tracked tokens. Unless this page has changed its tracked tokens itself, the
|
|
||||||
// save then writes the stored list with only the field gone.
|
|
||||||
function clearBalanceNotFetched(stored, addresses) {
|
|
||||||
for (const list of [
|
|
||||||
stored.trackedTokens,
|
|
||||||
rawState.trackedTokens,
|
|
||||||
baseline.trackedTokens,
|
|
||||||
]) {
|
|
||||||
for (const token of list) {
|
|
||||||
if (addresses.includes(token.address.toLowerCase())) {
|
|
||||||
delete token.balanceNotFetched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rejects with StateUnusableError for a stored record this build cannot make
|
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||||
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
||||||
// caller that ignores the rejection gets StateNotLoadedError on the first
|
// caller that ignores the rejection gets StateNotLoadedError on the first
|
||||||
@@ -680,8 +636,6 @@ module.exports = {
|
|||||||
state,
|
state,
|
||||||
saveState,
|
saveState,
|
||||||
onSaveFailure,
|
onSaveFailure,
|
||||||
takeStoredNetwork,
|
|
||||||
clearBalanceNotFetched,
|
|
||||||
loadState,
|
loadState,
|
||||||
currentAddress,
|
currentAddress,
|
||||||
currentNetwork,
|
currentNetwork,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
|
||||||
// profile.
|
|
||||||
//
|
|
||||||
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
|
||||||
// wallet reads that service's setting from storage. The wallet reads the setting
|
|
||||||
// again when the answer lands. While the setting is off, no such request is
|
|
||||||
// made, and an answer that lands after the setting went off is dropped. A page's
|
|
||||||
// own copy of the setting is not used, because another page may have just
|
|
||||||
// changed it.
|
|
||||||
|
|
||||||
const { storageGet } = require("./browserApi");
|
|
||||||
const { normalizePersisted } = require("./persistedState");
|
|
||||||
|
|
||||||
// Whether the setting `name` is on in the stored profile. `name` is
|
|
||||||
// "useBlockscout" or "checkRecipientsOnEtherscan". The profile is read through
|
|
||||||
// normalizePersisted(), as a load reads it, so only a stored true is on. A
|
|
||||||
// failed read counts as off.
|
|
||||||
async function storedSettingIsOn(name) {
|
|
||||||
try {
|
|
||||||
const { autistmask } = await storageGet("autistmask");
|
|
||||||
return normalizePersisted(autistmask)[name];
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { storedSettingIsOn };
|
|
||||||
@@ -576,7 +576,7 @@ const TOKENS = [
|
|||||||
symbol: "FLOKI",
|
symbol: "FLOKI",
|
||||||
decimals: 9,
|
decimals: 9,
|
||||||
name: "FLOKI",
|
name: "FLOKI",
|
||||||
url: "https://floki.com",
|
url: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
address: "0x7420B4b9a0110cdC71fB720908340C03F9Bc03EC",
|
address: "0x7420B4b9a0110cdC71fB720908340C03F9Bc03EC",
|
||||||
@@ -1290,7 +1290,7 @@ const TOKENS = [
|
|||||||
symbol: "TURBO",
|
symbol: "TURBO",
|
||||||
decimals: 18,
|
decimals: 18,
|
||||||
name: "Turbo",
|
name: "Turbo",
|
||||||
url: "https://www.turbo-eth.com",
|
url: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
address: "0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2",
|
address: "0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2",
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const { toDecimals } = require("./transferAmount");
|
|||||||
// truncation without the approval screens' nonzero floor: the transaction
|
// truncation without the approval screens' nonzero floor: the transaction
|
||||||
// detail view is the authoritative record and already shows exact precision.
|
// detail view is the authoritative record and already shows exact precision.
|
||||||
const { truncateAmount: formatTxValue } = require("./amountDisplay");
|
const { truncateAmount: formatTxValue } = require("./amountDisplay");
|
||||||
const { routerRelease } = require("./uniswap");
|
|
||||||
const { storedSettingIsOn } = require("./storedSettings");
|
|
||||||
|
|
||||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||||
// over the address, not part of its identity. Every address comparison in
|
// over the address, not part of its identity. Every address comparison in
|
||||||
@@ -53,6 +51,7 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
}
|
}
|
||||||
// Map known DEX methods to "Swap" for cleaner display
|
// Map known DEX methods to "Swap" for cleaner display
|
||||||
const SWAP_METHODS = new Set([
|
const SWAP_METHODS = new Set([
|
||||||
|
"execute",
|
||||||
"swap",
|
"swap",
|
||||||
"swapExactTokensForTokens",
|
"swapExactTokensForTokens",
|
||||||
"swapTokensForExactTokens",
|
"swapTokensForExactTokens",
|
||||||
@@ -60,16 +59,9 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
"swapTokensForExactETH",
|
"swapTokensForExactETH",
|
||||||
"swapExactTokensForETH",
|
"swapExactTokensForETH",
|
||||||
"swapETHForExactTokens",
|
"swapETHForExactTokens",
|
||||||
|
"multicall",
|
||||||
]);
|
]);
|
||||||
// Multisigs, smart accounts and many other contracts also have an
|
const label = SWAP_METHODS.has(method)
|
||||||
// `execute` or `multicall` method, so either is a swap only when sent
|
|
||||||
// to a Universal Router on this transaction's network: the same
|
|
||||||
// addresses the approval screen decodes a swap for.
|
|
||||||
const isSwap =
|
|
||||||
SWAP_METHODS.has(method) ||
|
|
||||||
((method === "execute" || method === "multicall") &&
|
|
||||||
routerRelease(to, chainId) !== null);
|
|
||||||
const label = isSwap
|
|
||||||
? "Swap"
|
? "Swap"
|
||||||
: method.charAt(0).toUpperCase() + method.slice(1);
|
: method.charAt(0).toUpperCase() + method.slice(1);
|
||||||
direction = "contract";
|
direction = "contract";
|
||||||
@@ -236,17 +228,12 @@ function mergeTransactions(txs, tokenTransfers) {
|
|||||||
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
|
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
|
||||||
// carries it, and a native entry is labelled with that network's
|
// carries it, and a native entry is labelled with that network's
|
||||||
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
|
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
|
||||||
//
|
|
||||||
// While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
|
|
||||||
// that lands after the setting went off is dropped
|
|
||||||
// (src/shared/storedSettings.js). Either way the answer is an empty list.
|
|
||||||
async function fetchRecentTransactions(
|
async function fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
chainId,
|
chainId,
|
||||||
count = 25,
|
count = 25,
|
||||||
) {
|
) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
|
||||||
log.debugf("fetchRecentTransactions", address);
|
log.debugf("fetchRecentTransactions", address);
|
||||||
const addrLower = normalizeAddress(address);
|
const addrLower = normalizeAddress(address);
|
||||||
|
|
||||||
@@ -277,7 +264,6 @@ async function fetchRecentTransactions(
|
|||||||
|
|
||||||
const txJson = txResp.ok ? await txResp.json() : {};
|
const txJson = txResp.ok ? await txResp.json() : {};
|
||||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
|
||||||
|
|
||||||
const txs = mergeTransactions(
|
const txs = mergeTransactions(
|
||||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
|
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
// encoded with.
|
// encoded with.
|
||||||
//
|
//
|
||||||
// A token amount is a decimal string plus a scale, and the two come from
|
// A token amount is a decimal string plus a scale, and the two come from
|
||||||
// different places. The confirmation screen renders the amount, the symbol and
|
// different places. The confirmation screen renders the amount, the balance and
|
||||||
// the balance it reads from the node with the block explorer's cached metadata
|
// the symbol from the block explorer's cached metadata (see
|
||||||
// (see fetchTokenBalances() in balances.js); the transfer used to be encoded
|
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
|
||||||
// from decimals() read off the contract at signing time, and nothing compared
|
// decimals() read off the contract at signing time, and nothing compared the
|
||||||
// the two. A token whose on-chain scale differs from the cached one — an
|
// two. A token whose on-chain scale differs from the cached one — an
|
||||||
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
|
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
|
||||||
// entry — therefore signed an amount that was never displayed, off by a power
|
// entry — therefore signed an amount that was never displayed, off by a power
|
||||||
// of ten for every decimal place of disagreement.
|
// of ten for every decimal place of disagreement.
|
||||||
|
|||||||
+13
-39
@@ -1,5 +1,4 @@
|
|||||||
// The amount check and balance arithmetic for the Send and transaction
|
// Balance arithmetic for the Send and transaction confirmation screens.
|
||||||
// confirmation screens.
|
|
||||||
//
|
//
|
||||||
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
||||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||||
@@ -21,7 +20,7 @@ const FEE_KNOWN = "known";
|
|||||||
const FEE_UNAVAILABLE = "unavailable";
|
const FEE_UNAVAILABLE = "unavailable";
|
||||||
|
|
||||||
const CODES = {
|
const CODES = {
|
||||||
// The amount is one isValidAmount() refuses.
|
// The amount is not a non-negative number we can do exact arithmetic on.
|
||||||
AMOUNT_INVALID: "amount-invalid",
|
AMOUNT_INVALID: "amount-invalid",
|
||||||
// ERC-20: the token amount exceeds the token balance.
|
// ERC-20: the token amount exceeds the token balance.
|
||||||
INSUFFICIENT_TOKEN: "insufficient-token",
|
INSUFFICIENT_TOKEN: "insufficient-token",
|
||||||
@@ -88,31 +87,10 @@ function toFixedPoint(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether `amount` can be sent: a number above zero, written as digits with
|
|
||||||
// at most one decimal point ("0.5", "12", ".5"), with nothing but zeros after
|
|
||||||
// the token's last decimal place, `tokenDecimals`, and nothing but zeros after
|
|
||||||
// the 18th, the places the arithmetic here works in, which are ETH's. ETH, and
|
|
||||||
// a token whose decimal places are unknown, pass null and are held to the 18
|
|
||||||
// (the confirmation screen refuses to send such a token anyway). Zeros past
|
|
||||||
// those places are accepted because parseUnits() drops them here and again
|
|
||||||
// when the transfer is encoded, so "1.0000000" of a 6-place token is exactly
|
|
||||||
// 1 everywhere. Refuses "1e-3", "1abc", "0" and "-1". The Send screen checks
|
|
||||||
// it before the confirmation screen opens, and validateTransfer() checks it
|
|
||||||
// again there.
|
|
||||||
function isValidAmount(amount, tokenDecimals = null) {
|
|
||||||
if (typeof amount !== "string") return false;
|
|
||||||
const places = Math.min(tokenDecimals ?? SCALE_DECIMALS, SCALE_DECIMALS);
|
|
||||||
try {
|
|
||||||
return parseUnits(amount.trim(), places) > 0n;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The most ETH a send can carry: the exact balance minus the fee reserve from
|
// The most ETH a send can carry: the exact balance minus the fee reserve from
|
||||||
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
|
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
|
||||||
// exactly that reserve left behind. `ethBalance` is an exact decimal string as
|
// exactly that reserve left behind. `ethBalance` is the exact decimal string
|
||||||
// formatEther() writes it, never a rounded one. Null when the balance does not
|
// balances.js stores, never a rounded one. Null when the balance does not
|
||||||
// leave anything to send once the fee is paid, or when either input is
|
// leave anything to send once the fee is paid, or when either input is
|
||||||
// unusable.
|
// unusable.
|
||||||
function maxEthAmount(ethBalance, feeWei) {
|
function maxEthAmount(ethBalance, feeWei) {
|
||||||
@@ -134,11 +112,9 @@ function maxTokenAmount(tokenBalance) {
|
|||||||
// Validate a pending transfer against the balances that must cover it.
|
// Validate a pending transfer against the balances that must cover it.
|
||||||
//
|
//
|
||||||
// isErc20 — token transfer rather than a native ETH transfer
|
// isErc20 — token transfer rather than a native ETH transfer
|
||||||
// amount — human decimal string being sent. One isValidAmount()
|
// amount — human decimal string being sent, non-negative. Anything
|
||||||
// refuses, a negative value included, is an unusable amount
|
// else, a negative value included, is an unusable amount
|
||||||
// rather than an amount that passes every comparison.
|
// rather than an amount that passes every comparison.
|
||||||
// tokenDecimals — ERC-20: the token's decimal places, or null when they
|
|
||||||
// are unknown. Passed to isValidAmount().
|
|
||||||
// ethBalance — human decimal string, the sender's ETH balance
|
// ethBalance — human decimal string, the sender's ETH balance
|
||||||
// tokenBalance — human decimal string, the sender's token balance
|
// tokenBalance — human decimal string, the sender's token balance
|
||||||
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
||||||
@@ -152,7 +128,6 @@ function maxTokenAmount(tokenBalance) {
|
|||||||
function validateTransfer({
|
function validateTransfer({
|
||||||
isErc20 = false,
|
isErc20 = false,
|
||||||
amount,
|
amount,
|
||||||
tokenDecimals = null,
|
|
||||||
ethBalance,
|
ethBalance,
|
||||||
tokenBalance,
|
tokenBalance,
|
||||||
feeStatus = FEE_PENDING,
|
feeStatus = FEE_PENDING,
|
||||||
@@ -160,17 +135,17 @@ function validateTransfer({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
const codes = [];
|
const codes = [];
|
||||||
|
|
||||||
// An amount refused here never reaches the comparisons below: a negative
|
const amountFp = toFixedPoint(amount);
|
||||||
// one would pass every one of them, then die at encode time in
|
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
||||||
// parseEther().
|
|
||||||
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
|
// A negative amount parses to a valid bigint, so every comparison below
|
||||||
|
// is trivially false and the send clears the screen — then dies at encode
|
||||||
|
// time in parseEther(). Unusable, on the same footing as a malformed fee.
|
||||||
|
if (amountFp === null || amountFp < 0n) {
|
||||||
codes.push(CODES.AMOUNT_INVALID);
|
codes.push(CODES.AMOUNT_INVALID);
|
||||||
return { canSend: false, codes };
|
return { canSend: false, codes };
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountFp = toFixedPoint(amount);
|
|
||||||
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
|
||||||
|
|
||||||
// Fail closed. Anything that is not a usable fee under a recognised
|
// Fail closed. Anything that is not a usable fee under a recognised
|
||||||
// status — a malformed feeWei, or a status this module does not know —
|
// status — a malformed feeWei, or a status this module does not know —
|
||||||
// is an unavailable estimate, never a fee of zero. Every such input errs
|
// is an unavailable estimate, never a fee of zero. Every such input errs
|
||||||
@@ -220,7 +195,6 @@ module.exports = {
|
|||||||
SCALE_DECIMALS,
|
SCALE_DECIMALS,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
isValidAmount,
|
|
||||||
maxEthAmount,
|
maxEthAmount,
|
||||||
maxTokenAmount,
|
maxTokenAmount,
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
|
|||||||
+253
-1972
File diff suppressed because it is too large
Load Diff
+6
-32
@@ -110,33 +110,16 @@ function addressFromPrivateKey(key) {
|
|||||||
return w.address;
|
return w.address;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The signer for `addr`, one of the wallet's stored address records. An HD or
|
function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
||||||
// xprv address is derived at its own `index`, never at its position in the
|
|
||||||
// wallet's list: the two differ once an import scan passes over an unused
|
|
||||||
// index, or once an address that is not the last is removed. An HD or xprv
|
|
||||||
// record without a valid `index` is refused before anything is derived.
|
|
||||||
// Whatever the wallet type, the key is refused unless it is the key of
|
|
||||||
// `addr.address`.
|
|
||||||
function getSignerForAddress(walletData, addr, decryptedSecret) {
|
|
||||||
if (
|
|
||||||
(walletData.type === "hd" || walletData.type === "xprv") &&
|
|
||||||
!(Number.isInteger(addr.index) && addr.index >= 0)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"No derivation index is stored for " +
|
|
||||||
addr.address +
|
|
||||||
", so its key cannot be derived.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let signer;
|
|
||||||
if (walletData.type === "hd") {
|
if (walletData.type === "hd") {
|
||||||
const node = HDNodeWallet.fromPhrase(
|
const node = HDNodeWallet.fromPhrase(
|
||||||
decryptedSecret,
|
decryptedSecret,
|
||||||
"",
|
"",
|
||||||
BIP44_ETH_PATH,
|
BIP44_ETH_PATH,
|
||||||
);
|
);
|
||||||
signer = node.deriveChild(addr.index);
|
return node.deriveChild(addrIndex);
|
||||||
} else if (walletData.type === "xprv") {
|
}
|
||||||
|
if (walletData.type === "xprv") {
|
||||||
// Checked here rather than through masterXprvOrThrow so the message
|
// Checked here rather than through masterXprvOrThrow so the message
|
||||||
// fits the situation: nobody is importing anything at signing time,
|
// fits the situation: nobody is importing anything at signing time,
|
||||||
// and this wallet is already in storage. src/shared/walletDefects.js
|
// and this wallet is already in storage. src/shared/walletDefects.js
|
||||||
@@ -154,18 +137,9 @@ function getSignerForAddress(walletData, addr, decryptedSecret) {
|
|||||||
"is not a master key, so it cannot sign.",
|
"is not a master key, so it cannot sign.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
signer = node.derivePath(BIP44_ETH_PATH).deriveChild(addr.index);
|
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
|
||||||
} else {
|
|
||||||
signer = new Wallet(decryptedSecret);
|
|
||||||
}
|
}
|
||||||
if (signer.address.toLowerCase() !== addr.address.toLowerCase()) {
|
return new Wallet(decryptedSecret);
|
||||||
throw new Error(
|
|
||||||
"The key this wallet holds for " +
|
|
||||||
addr.address +
|
|
||||||
" belongs to a different address, so it was not used.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return signer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidMnemonic(mnemonic) {
|
function isValidMnemonic(mnemonic) {
|
||||||
|
|||||||
@@ -36,13 +36,11 @@ const DEFECTS = {
|
|||||||
"derivation path beneath that key instead of from a master " +
|
"derivation path beneath that key instead of from a master " +
|
||||||
"key, so the addresses listed here are not the ones that key " +
|
"key, so the addresses listed here are not the ones that key " +
|
||||||
"produces under the standard path.",
|
"produces under the standard path.",
|
||||||
"Signing, sending and private key export are disabled for this " +
|
"Signing and sending are disabled for this wallet. The addresses " +
|
||||||
"wallet. Anything these addresses hold can be reached outside " +
|
"do descend from the extended private key you imported, so " +
|
||||||
"this wallet with the extended private key you imported, if " +
|
"anything they hold is still reachable by software that " +
|
||||||
"you kept a copy of it, or with the master extended private " +
|
"repeats the same non-standard derivation. Check them in a " +
|
||||||
"key or recovery phrase it came from, in software that " +
|
"block explorer before deciding what to do.",
|
||||||
"repeats the same non-standard derivation. Check the " +
|
|
||||||
"addresses in a block explorer before deciding what to do.",
|
|
||||||
"To see the addresses this key produces under the standard path, " +
|
"To see the addresses this key produces under the standard path, " +
|
||||||
"import the master extended private key, or the recovery " +
|
"import the master extended private key, or the recovery " +
|
||||||
"phrase it came from, as a new wallet. Nothing here has been " +
|
"phrase it came from, as a new wallet. Nothing here has been " +
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
|
|||||||
|
|
||||||
// Forget every site permission held against the given addresses: the
|
// Forget every site permission held against the given addresses: the
|
||||||
// remembered ones in `state`, and the connections approved without
|
// remembered ones in `state`, and the connections approved without
|
||||||
// "Remember", which only the background holds, in session storage.
|
// "Remember", which only the background holds, in memory.
|
||||||
function dropSitePermissions(state, addresses) {
|
function dropSitePermissions(state, addresses) {
|
||||||
for (const addr of addresses) {
|
for (const addr of addresses) {
|
||||||
delete state.allowedSites[addr];
|
delete state.allowedSites[addr];
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
// A page whose origin has no host is never told an address by accountsChanged
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/564).
|
|
||||||
//
|
|
||||||
// When the user switches address, the background tells each open tab the new
|
|
||||||
// active address if the site at the tab's URL is connected. A page at a
|
|
||||||
// connected site's URL can still have another origin: one its server
|
|
||||||
// sandboxes with a Content-Security-Policy has the origin "null", and every
|
|
||||||
// request it makes is refused. Each test here switches the address in the real
|
|
||||||
// background, takes the event it sent to a tab at the site's URL, and hands
|
|
||||||
// that event to the real content script in a page of the given origin.
|
|
||||||
|
|
||||||
const { networkById } = require("../src/shared/networks");
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
|
|
||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const SITE = "https://dapp.example";
|
|
||||||
|
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
|
||||||
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
|
|
||||||
|
|
||||||
// One address, with SITE remembered as allowed for it.
|
|
||||||
function storedProfile() {
|
|
||||||
const mainnet = networkById("mainnet");
|
|
||||||
return {
|
|
||||||
hasWallet: true,
|
|
||||||
wallets: [
|
|
||||||
{
|
|
||||||
name: "Wallet 1",
|
|
||||||
type: "hd",
|
|
||||||
addresses: [
|
|
||||||
{ address: ADDRESS, balance: "0", tokenBalances: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeAddress: ADDRESS,
|
|
||||||
networkId: "mainnet",
|
|
||||||
rpcUrl: mainnet.defaultRpcUrl,
|
|
||||||
blockscoutUrl: mainnet.defaultBlockscoutUrl,
|
|
||||||
allowedSites: { [ADDRESS]: [SITE] },
|
|
||||||
deniedSites: {},
|
|
||||||
trackedTokens: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.chrome;
|
|
||||||
delete global.window;
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The popup tells the background the active address changed, and the one
|
|
||||||
// open tab, at SITE's URL, is sent an event. That event is returned.
|
|
||||||
async function eventSentToSiteTab() {
|
|
||||||
jest.resetModules();
|
|
||||||
jest.doMock("../src/shared/balances", () => ({
|
|
||||||
getProvider: () => ({}),
|
|
||||||
refreshBalances: jest.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
||||||
isPhishingDomain: () => false,
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/shared/alarms", () => ({
|
|
||||||
BALANCE_REFRESH_ALARM: "balance",
|
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
||||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
|
||||||
registerAlarmHandlers: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let messageListener = null;
|
|
||||||
const sentToTabs = [];
|
|
||||||
global.chrome = {
|
|
||||||
storage: makeStorageStub({ autistmask: storedProfile() }),
|
|
||||||
runtime: {
|
|
||||||
getURL: (path) => EXT_URL + path,
|
|
||||||
onMessage: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
messageListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onConnect: { addListener: () => {} },
|
|
||||||
getPlatformInfo: (cb) => cb({ os: "linux" }),
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
windows: {
|
|
||||||
remove: (id, cb) => {
|
|
||||||
if (cb) cb();
|
|
||||||
},
|
|
||||||
onRemoved: { addListener: () => {} },
|
|
||||||
},
|
|
||||||
tabs: {
|
|
||||||
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
|
|
||||||
sendMessage: (tabId, msg, cb) => {
|
|
||||||
sentToTabs.push(msg);
|
|
||||||
if (cb) cb();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
action: { setPopup: () => {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
require("../src/background/index");
|
|
||||||
messageListener(
|
|
||||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
||||||
FROM_POPUP,
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(sentToTabs).toHaveLength(1);
|
|
||||||
return sentToTabs[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The content script in a page whose origin is `pageOrigin` is handed `event`
|
|
||||||
// from the background. What it posts to the page is returned.
|
|
||||||
function postedToPage(event, pageOrigin) {
|
|
||||||
jest.resetModules();
|
|
||||||
let eventListener = null;
|
|
||||||
const posted = [];
|
|
||||||
global.chrome = {
|
|
||||||
runtime: {
|
|
||||||
onMessage: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
eventListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
global.window = {
|
|
||||||
origin: pageOrigin,
|
|
||||||
addEventListener: () => {},
|
|
||||||
postMessage: (data) => posted.push(data),
|
|
||||||
};
|
|
||||||
|
|
||||||
require("../src/content/index");
|
|
||||||
eventListener(event);
|
|
||||||
|
|
||||||
return posted;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("accountsChanged after an address switch, at a connected site's URL", () => {
|
|
||||||
test("the site's own page is told the active address", async () => {
|
|
||||||
const event = await eventSentToSiteTab();
|
|
||||||
|
|
||||||
expect(postedToPage(event, SITE)).toEqual([
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [ADDRESS],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page whose origin has no host is told no address", async () => {
|
|
||||||
const event = await eventSentToSiteTab();
|
|
||||||
|
|
||||||
expect(postedToPage(event, "null")).toEqual([
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
// After "Add" on AddToken, AddressDetail lists the new token. Its line reads
|
|
||||||
// `quantity unknown`, not 0.0000, until a balance refresh has read the token's
|
|
||||||
// balance at every address, even if the popup closes and opens again
|
|
||||||
// meanwhile. A token added on SettingsAddToken reads the same wherever it is
|
|
||||||
// listed. A refresh that returns draws the balances on AddressDetail or
|
|
||||||
// AddressToken again, so the fetched balance shows without the screen being
|
|
||||||
// reopened (https://git.eeqj.de/sneak/AutistMask/issues/516).
|
|
||||||
//
|
|
||||||
// The tracked token's record carries `balanceNotFetched` until a refresh has
|
|
||||||
// read the token's balance. Removing the token removes that record. A network
|
|
||||||
// switch keeps each network's records apart. A refresh takes the field off the
|
|
||||||
// stored record's tokens and keeps another page's changes to the stored list.
|
|
||||||
//
|
|
||||||
// Driven through the real popup over a stored profile on Ethereum Mainnet,
|
|
||||||
// with "Show tracked tokens with zero balance" on, as it is by default.
|
|
||||||
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
// Tracked by nobody until a test adds it.
|
|
||||||
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
|
||||||
|
|
||||||
// A holding of a million NEW_TOKEN, as refreshBalances() stores it.
|
|
||||||
const MILLION = {
|
|
||||||
address: NEW_TOKEN,
|
|
||||||
name: "New Token",
|
|
||||||
symbol: "NEW",
|
|
||||||
decimals: 18,
|
|
||||||
balance: "1000000.0",
|
|
||||||
holders: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
// NEW_TOKEN as "Add" on AddToken tracks it, once a refresh has read its
|
|
||||||
// balance.
|
|
||||||
const NEW_TRACKED = {
|
|
||||||
address: NEW_TOKEN,
|
|
||||||
symbol: "NEW",
|
|
||||||
name: "New Token",
|
|
||||||
decimals: 18,
|
|
||||||
};
|
|
||||||
|
|
||||||
// A token that another popup page tracks.
|
|
||||||
const OTHER_TOKEN = {
|
|
||||||
address: "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
|
|
||||||
symbol: "OTH",
|
|
||||||
name: "Other Token",
|
|
||||||
decimals: 18,
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The popup reopened onto AddressDetail for the one address.
|
|
||||||
function bootOnAddress(extra) {
|
|
||||||
return bootPopup(
|
|
||||||
unversionedValidProfile({
|
|
||||||
currentView: "address",
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
...extra,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Holds the next balance refresh open. The returned function lets it finish
|
|
||||||
// with the address's ETH balance at 2 and `tokenBalances` as its token
|
|
||||||
// entries, having read the balance of every token tracked when it started.
|
|
||||||
// Given null, it finishes having read no token's balance, as when every
|
|
||||||
// balanceOf fails, and leaves the token entries as they were.
|
|
||||||
function holdNextRefresh(page) {
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
|
||||||
let release;
|
|
||||||
refreshBalances.mockImplementationOnce(
|
|
||||||
async (wallets, rpcUrl, explorerUrl, trackedTokens) => {
|
|
||||||
const read = trackedTokens.map((t) => t.address.toLowerCase());
|
|
||||||
const tokenBalances = await new Promise((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
wallets[0].addresses[0].balance = "2.0";
|
|
||||||
if (tokenBalances === null) return [];
|
|
||||||
wallets[0].addresses[0].tokenBalances = tokenBalances;
|
|
||||||
return read;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return async (tokenBalances) => {
|
|
||||||
release(tokenBalances);
|
|
||||||
await page.settle();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs the ten-second refresh to the end, finishing it as holdNextRefresh()
|
|
||||||
// does with `tokenBalances`.
|
|
||||||
async function refreshOnce(page, tokenBalances) {
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
const ticking = page.tick();
|
|
||||||
await page.settle();
|
|
||||||
await finish(tokenBalances);
|
|
||||||
await ticking;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup
|
|
||||||
// answering at once.
|
|
||||||
async function addNewToken(page) {
|
|
||||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
||||||
lookupTokenInfo.mockResolvedValueOnce({
|
|
||||||
symbol: "NEW",
|
|
||||||
decimals: 18,
|
|
||||||
name: "New Token",
|
|
||||||
});
|
|
||||||
await page.click("btn-add-token");
|
|
||||||
page.node("add-token-address").value = NEW_TOKEN;
|
|
||||||
await page.click("btn-add-token-confirm");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract
|
|
||||||
// lookup answering at once. It ends on SettingsAddToken.
|
|
||||||
async function addOnSettingsAddToken(page) {
|
|
||||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
||||||
lookupTokenInfo.mockResolvedValueOnce({
|
|
||||||
symbol: "NEW",
|
|
||||||
decimals: 18,
|
|
||||||
name: "New Token",
|
|
||||||
});
|
|
||||||
await page.click("btn-settings-add-token");
|
|
||||||
page.node("settings-addtoken-address").value = NEW_TOKEN;
|
|
||||||
await page.click("btn-settings-addtoken-manual");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "[x]" button Settings draws beside the first tracked token. The page
|
|
||||||
// stub does not turn the markup Settings draws into elements. So the list
|
|
||||||
// hands Settings a stand-in for that button each time it is drawn, and
|
|
||||||
// `press()` presses the last one drawn. Call it before Settings opens.
|
|
||||||
function firstRemoveButton(page) {
|
|
||||||
const button = { press: null };
|
|
||||||
page.node("settings-tracked-tokens").querySelectorAll = () => [
|
|
||||||
{
|
|
||||||
dataset: { idx: "0" },
|
|
||||||
addEventListener: (name, fn) => {
|
|
||||||
button.press = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picks `networkId` in the Settings network selector, as the user does.
|
|
||||||
async function chooseNetwork(page, networkId) {
|
|
||||||
const select = page.node("settings-network");
|
|
||||||
select.value = networkId;
|
|
||||||
for (const fn of select.listeners.change) await fn();
|
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Another popup page saves `trackedTokens` as the tracked token list. This
|
|
||||||
// page is not told of the write.
|
|
||||||
function anotherPageTracks(page, trackedTokens) {
|
|
||||||
page.storage.write("autistmask", {
|
|
||||||
...page.storage.read("autistmask"),
|
|
||||||
trackedTokens,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
|
|
||||||
// another is named, as markup.
|
|
||||||
function newTokenRow(page, id = "address-balances") {
|
|
||||||
return page
|
|
||||||
.node(id)
|
|
||||||
.innerHTML.split("<div ")
|
|
||||||
.find((row) =>
|
|
||||||
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddressDetail for the one address opened from Home, as its `[info]` button
|
|
||||||
// does.
|
|
||||||
async function openAddress(page) {
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
require("../src/popup/views/helpers").pushCurrentView();
|
|
||||||
require("../src/popup/views/addressDetail").show();
|
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddressToken for `token` opened from AddressDetail, as tapping the token's
|
|
||||||
// row there does.
|
|
||||||
async function openToken(page, token) {
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
state.selectedToken = token;
|
|
||||||
require("../src/popup/views/helpers").pushCurrentView();
|
|
||||||
require("../src/popup/views/addressToken").show();
|
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => {
|
|
||||||
const page = await bootOnAddress();
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
|
|
||||||
await addNewToken(page);
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
const row = newTokenRow(page);
|
|
||||||
expect(row).toContain("quantity unknown");
|
|
||||||
expect(row).not.toContain("0.0000");
|
|
||||||
await finish([MILLION]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => {
|
|
||||||
const page = await bootOnAddress();
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
await addNewToken(page);
|
|
||||||
// Stands for the transactions on the screen, which only show() replaces.
|
|
||||||
page.node("tx-list").innerHTML = "transactions on the screen";
|
|
||||||
|
|
||||||
await finish([MILLION]);
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
expect(newTokenRow(page)).toContain("1000000.0000");
|
|
||||||
// Only the balances are drawn again, not the transaction list.
|
|
||||||
expect(page.node("tx-list").innerHTML).toBe("transactions on the screen");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh already running when the token is added does not count as reading its balance", async () => {
|
|
||||||
const page = await bootOnAddress();
|
|
||||||
const finishRunning = holdNextRefresh(page);
|
|
||||||
const ticking = page.tick();
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
await addNewToken(page);
|
|
||||||
await finishRunning([]);
|
|
||||||
await ticking;
|
|
||||||
|
|
||||||
expect(newTokenRow(page)).toContain("quantity unknown");
|
|
||||||
|
|
||||||
// The next one reads the token's balance, which is zero.
|
|
||||||
await refreshOnce(page, []);
|
|
||||||
|
|
||||||
expect(newTokenRow(page)).toContain("0.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => {
|
|
||||||
const closed = await bootOnAddress();
|
|
||||||
holdNextRefresh(closed);
|
|
||||||
await addNewToken(closed);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
// The refresh this boot starts reads no token's balance: the boot's stub
|
|
||||||
// of refreshBalances() reads none.
|
|
||||||
const page = await bootPopup(undefined, { storage: closed.storage });
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
const row = newTokenRow(page);
|
|
||||||
expect(row).toContain("quantity unknown");
|
|
||||||
expect(row).not.toContain("0.0000");
|
|
||||||
|
|
||||||
await refreshOnce(page, [MILLION]);
|
|
||||||
|
|
||||||
expect(newTokenRow(page)).toContain("1000000.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => {
|
|
||||||
const page = await bootOnAddress();
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
await addNewToken(page);
|
|
||||||
|
|
||||||
await finish(null);
|
|
||||||
|
|
||||||
// Drawn again: the ETH balance it fetched is shown.
|
|
||||||
expect(page.node("address-balances").innerHTML).toContain("2.0000");
|
|
||||||
expect(newTokenRow(page)).toContain("quantity unknown");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => {
|
|
||||||
const page = await bootOnAddress();
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
await addNewToken(page);
|
|
||||||
|
|
||||||
await openToken(page, NEW_TOKEN.toLowerCase());
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["address-token"]);
|
|
||||||
const before = page.node("address-token-balance").innerHTML;
|
|
||||||
expect(before).toContain("quantity unknown");
|
|
||||||
expect(before).not.toContain("0.0000");
|
|
||||||
|
|
||||||
await finish([MILLION]);
|
|
||||||
|
|
||||||
expect(page.node("address-token-balance").innerHTML).toContain(
|
|
||||||
"1000000.0000",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await addOnSettingsAddToken(page);
|
|
||||||
await page.click("btn-settings-addtoken-back");
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
|
||||||
const onHome = newTokenRow(page, "wallet-list");
|
|
||||||
expect(onHome).toContain("quantity unknown");
|
|
||||||
expect(onHome).not.toContain("0.0000");
|
|
||||||
|
|
||||||
await openAddress(page);
|
|
||||||
const onAddress = newTokenRow(page);
|
|
||||||
expect(onAddress).toContain("quantity unknown");
|
|
||||||
expect(onAddress).not.toContain("0.0000");
|
|
||||||
|
|
||||||
await finish([MILLION]);
|
|
||||||
|
|
||||||
expect(newTokenRow(page)).toContain("1000000.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh that returns while AddressToken is up draws its balance again", async () => {
|
|
||||||
const page = await bootPopup(
|
|
||||||
unversionedValidProfile({
|
|
||||||
currentView: "address-token",
|
|
||||||
viewStack: ["main", "address"],
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
selectedToken: "ETH",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(page.visibleViews()).toEqual(["address-token"]);
|
|
||||||
expect(page.node("address-token-balance").innerHTML).toContain("1.5000");
|
|
||||||
|
|
||||||
await refreshOnce(page, null);
|
|
||||||
|
|
||||||
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
const removeButton = firstRemoveButton(page);
|
|
||||||
const stored = () => page.storage.read("autistmask");
|
|
||||||
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await addOnSettingsAddToken(page);
|
|
||||||
// Settings opened again from Home draws its list with the token in it.
|
|
||||||
await page.click("btn-settings-addtoken-back");
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await removeButton.press();
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(stored().trackedTokens).toEqual([]);
|
|
||||||
// Nothing in the stored record names the token any more.
|
|
||||||
expect(JSON.stringify(stored()).toLowerCase()).not.toContain(
|
|
||||||
NEW_TOKEN.toLowerCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await addOnSettingsAddToken(page);
|
|
||||||
// A second add of the same token is refused.
|
|
||||||
page.node("settings-addtoken-address").value = NEW_TOKEN;
|
|
||||||
await page.click("btn-settings-addtoken-manual");
|
|
||||||
|
|
||||||
const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 };
|
|
||||||
expect(stored().trackedTokens).toEqual([
|
|
||||||
{ ...added, name: "New Token", balanceNotFetched: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await refreshOnce(page, []);
|
|
||||||
|
|
||||||
expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => {
|
|
||||||
// Sepolia tracks a token at the same contract address, also just added.
|
|
||||||
const sepoliaToken = {
|
|
||||||
address: NEW_TOKEN,
|
|
||||||
symbol: "SEP",
|
|
||||||
name: "Sepolia Token",
|
|
||||||
decimals: 18,
|
|
||||||
};
|
|
||||||
const page = await bootOnAddress({
|
|
||||||
networkTrackedTokens: {
|
|
||||||
sepolia: [{ ...sepoliaToken, balanceNotFetched: true }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const finishMainnet = holdNextRefresh(page);
|
|
||||||
await addNewToken(page);
|
|
||||||
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await chooseNetwork(page, "sepolia");
|
|
||||||
// The refresh Add started returns after the switch and writes nothing.
|
|
||||||
await finishMainnet([MILLION]);
|
|
||||||
// Sepolia's refresh reads the balance of its own token.
|
|
||||||
await refreshOnce(page, []);
|
|
||||||
|
|
||||||
const stored = page.storage.read("autistmask");
|
|
||||||
expect(stored.trackedTokens).toEqual([sepoliaToken]);
|
|
||||||
expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true);
|
|
||||||
|
|
||||||
await chooseNetwork(page, "mainnet");
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
expect(newTokenRow(page)).toContain("quantity unknown");
|
|
||||||
|
|
||||||
await refreshOnce(page, [MILLION]);
|
|
||||||
|
|
||||||
expect(newTokenRow(page)).toContain("1000000.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh keeps a token that another page added after this page loaded", async () => {
|
|
||||||
const page = await bootOnAddress();
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
await addNewToken(page);
|
|
||||||
const otherAdded = { ...OTHER_TOKEN, balanceNotFetched: true };
|
|
||||||
anotherPageTracks(page, [
|
|
||||||
{ ...NEW_TRACKED, balanceNotFetched: true },
|
|
||||||
otherAdded,
|
|
||||||
]);
|
|
||||||
|
|
||||||
await finish([MILLION]);
|
|
||||||
|
|
||||||
expect(page.storage.read("autistmask").trackedTokens).toEqual([
|
|
||||||
NEW_TRACKED,
|
|
||||||
otherAdded,
|
|
||||||
]);
|
|
||||||
expect(newTokenRow(page)).toContain("1000000.0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh does not bring back a token that another page removed after this page loaded", async () => {
|
|
||||||
const page = await bootOnAddress({ trackedTokens: [OTHER_TOKEN] });
|
|
||||||
const finish = holdNextRefresh(page);
|
|
||||||
await addNewToken(page);
|
|
||||||
anotherPageTracks(page, [{ ...NEW_TRACKED, balanceNotFetched: true }]);
|
|
||||||
|
|
||||||
await finish([MILLION]);
|
|
||||||
|
|
||||||
expect(page.storage.read("autistmask").trackedTokens).toEqual([
|
|
||||||
NEW_TRACKED,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
// Sending, a site's transaction and signature requests and private key export
|
|
||||||
// each use the key of the address they name: the key at the index that address
|
|
||||||
// was derived at, not the one at its position in the wallet's list. The two
|
|
||||||
// differ after an import whose scan passed over an unused index, and after an
|
|
||||||
// address that is not the last is removed
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/510). A send is signed by the
|
|
||||||
// From address its confirmation screen shows, whichever address is selected.
|
|
||||||
//
|
|
||||||
// Driven through the real import, "+" button, address removal, confirmation
|
|
||||||
// screen, transaction and signature approval screens and export screen, with
|
|
||||||
// the node, the vault and the DOM stubbed.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// The addresses the stub node reports a balance for, and the signed
|
|
||||||
// transactions it was handed.
|
|
||||||
const mockNode = { funded: new Set(), broadcast: [] };
|
|
||||||
|
|
||||||
jest.mock("ethers", () => {
|
|
||||||
const actual = jest.requireActual("ethers");
|
|
||||||
class StubProvider {
|
|
||||||
async getBalance(address) {
|
|
||||||
return mockNode.funded.has(address) ? 1n : 0n;
|
|
||||||
}
|
|
||||||
async getTransactionCount() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
async estimateGas() {
|
|
||||||
return 21000n;
|
|
||||||
}
|
|
||||||
async getFeeData() {
|
|
||||||
return {
|
|
||||||
maxFeePerGas: 20000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
gasPrice: 10000000000n,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async getNetwork() {
|
|
||||||
return { chainId: 1n };
|
|
||||||
}
|
|
||||||
async getCode() {
|
|
||||||
return "0x";
|
|
||||||
}
|
|
||||||
async lookupAddress() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
async broadcastTransaction(signed) {
|
|
||||||
mockNode.broadcast.push(signed);
|
|
||||||
return { hash: "0x" + "ab".repeat(32) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...actual, JsonRpcProvider: StubProvider };
|
|
||||||
});
|
|
||||||
|
|
||||||
// No password is hashed: what the wallet stores is the phrase itself.
|
|
||||||
jest.mock("../src/shared/vault", () => ({
|
|
||||||
encryptWithPassword: async (secret) => secret,
|
|
||||||
decryptWithPassword: async (secret) => secret,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The wait screen polls for a receipt; these tests stop at the broadcast.
|
|
||||||
jest.mock("../src/popup/views/txStatus", () => ({
|
|
||||||
showWait: jest.fn(),
|
|
||||||
showError: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
|
|
||||||
|
|
||||||
global.fetch = jest.fn(() => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
HDNodeWallet,
|
|
||||||
Mnemonic,
|
|
||||||
Transaction,
|
|
||||||
getBytes,
|
|
||||||
verifyMessage,
|
|
||||||
} = require("ethers");
|
|
||||||
|
|
||||||
// Publicly known development phrase; never fund it.
|
|
||||||
const PHRASE = "test test test test test test test test test test test junk";
|
|
||||||
// PHRASE's master extended private key, as the xprv import takes it.
|
|
||||||
const XPRV = HDNodeWallet.fromSeed(
|
|
||||||
Mnemonic.fromPhrase(PHRASE).computeSeed(),
|
|
||||||
).extendedKey;
|
|
||||||
const PASSWORD = "correct horse battery staple";
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
||||||
// "Hello", as the hex a page sends.
|
|
||||||
const MESSAGE = "0x48656c6c6f";
|
|
||||||
|
|
||||||
// The address and key at derivation index `index` of PHRASE, worked out with
|
|
||||||
// ethers directly rather than through the wallet's own code.
|
|
||||||
function derived(index) {
|
|
||||||
return HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0/" + index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ DOM stub
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
title: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
listeners: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: (name, fn) => {
|
|
||||||
el.listeners[name] = el.listeners[name] || [];
|
|
||||||
el.listeners[name].push(fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
addEventListener: () => {},
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(element) {
|
|
||||||
const event = { stopPropagation: () => {} };
|
|
||||||
return Promise.all((element.listeners.click || []).map((fn) => fn(event)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ harness
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const { removeAddressFromState } = require("../src/shared/walletDelete");
|
|
||||||
const addWallet = require("../src/popup/views/addWallet");
|
|
||||||
const home = require("../src/popup/views/home");
|
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const exportPrivkey = require("../src/popup/views/exportPrivkey");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
|
|
||||||
// What the background answers when the approval window asks which approval it
|
|
||||||
// was opened for, and every message the popup sent it.
|
|
||||||
let approvalDetails;
|
|
||||||
let sent;
|
|
||||||
|
|
||||||
// The "+" button of the wallet list's last render. Each render attaches the
|
|
||||||
// handler to the buttons it drew, as the browser's fresh list would have.
|
|
||||||
let plusButton;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
approvalDetails = null;
|
|
||||||
sent = [];
|
|
||||||
mockNode.broadcast = [];
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { close: () => {} };
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
sent.push(msg);
|
|
||||||
if (!reply) return;
|
|
||||||
reply(
|
|
||||||
msg.type === "AUTISTMASK_GET_APPROVAL"
|
|
||||||
? approvalDetails
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
node("wallet-list").querySelectorAll = (selector) => {
|
|
||||||
if (selector !== ".btn-add-address") return [];
|
|
||||||
plusButton = makeElement("btn-add-address");
|
|
||||||
plusButton.dataset.wallet = "0";
|
|
||||||
return [plusButton];
|
|
||||||
};
|
|
||||||
// The explorer and price lookups these screens start fail offline, and
|
|
||||||
// say so; that is not under test.
|
|
||||||
for (const method of ["log", "warn", "error"]) {
|
|
||||||
jest.spyOn(console, method).mockImplementation(() => {});
|
|
||||||
}
|
|
||||||
txStatus.showError.mockClear();
|
|
||||||
|
|
||||||
state.wallets = [];
|
|
||||||
state.rpcUrl = "https://rpc.example.invalid";
|
|
||||||
state.networkId = "mainnet";
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = "main";
|
|
||||||
|
|
||||||
addWallet.init({
|
|
||||||
renderWalletList: () => {},
|
|
||||||
startRefresh: () => {},
|
|
||||||
pageClosed: new AbortController().signal,
|
|
||||||
});
|
|
||||||
confirmTx.init({});
|
|
||||||
approval.init({});
|
|
||||||
exportPrivkey.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import PHRASE on the add-wallet screen, with the stub node reporting a
|
|
||||||
// balance at each of `fundedIndices`, and wait for the address scan.
|
|
||||||
async function importPhrase(fundedIndices) {
|
|
||||||
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
|
|
||||||
await click(node("tab-mnemonic"));
|
|
||||||
node("wallet-mnemonic").value = PHRASE;
|
|
||||||
node("add-wallet-password").value = PASSWORD;
|
|
||||||
node("add-wallet-password-confirm").value = PASSWORD;
|
|
||||||
await click(node("btn-add-wallet-confirm"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same, importing XPRV on the add-wallet screen's xprv tab.
|
|
||||||
async function importXprv(fundedIndices) {
|
|
||||||
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
|
|
||||||
await click(node("tab-xprv"));
|
|
||||||
node("import-xprv-key").value = XPRV;
|
|
||||||
node("add-wallet-password").value = PASSWORD;
|
|
||||||
node("add-wallet-password-confirm").value = PASSWORD;
|
|
||||||
await click(node("btn-add-wallet-confirm"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Press the wallet's "+" button on Home.
|
|
||||||
async function addAddress() {
|
|
||||||
home.render({
|
|
||||||
doRefreshAndRender: () => {},
|
|
||||||
pageClosed: new AbortController().signal,
|
|
||||||
});
|
|
||||||
await click(plusButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The key the export screen reveals for the address at `position`.
|
|
||||||
async function exportedKey(position) {
|
|
||||||
exportPrivkey.show(0, position);
|
|
||||||
node("export-privkey-password").value = PASSWORD;
|
|
||||||
await click(node("btn-export-privkey-confirm"));
|
|
||||||
return node("export-privkey-value").textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm a send whose From line shows `from`, with the address at position
|
|
||||||
// `selected` selected; resolves to the signed transaction the node was handed,
|
|
||||||
// or undefined when none was.
|
|
||||||
async function confirmSend(from, selected) {
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = selected;
|
|
||||||
confirmTx.show({
|
|
||||||
from,
|
|
||||||
to: RECIPIENT,
|
|
||||||
amount: "0.5",
|
|
||||||
token: "ETH",
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
node("confirm-tx-password").value = PASSWORD;
|
|
||||||
await click(node("btn-confirm-send"));
|
|
||||||
return mockNode.broadcast.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send from the address at `position`, with that address selected, as the Send
|
|
||||||
// button leaves it.
|
|
||||||
function sendFrom(position) {
|
|
||||||
return confirmSend(state.wallets[0].addresses[position].address, position);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site asks the address at `position` to sign MESSAGE; resolves to the
|
|
||||||
// response the approval window sent the background.
|
|
||||||
async function signFor(position) {
|
|
||||||
const address = state.wallets[0].addresses[position].address;
|
|
||||||
approvalDetails = {
|
|
||||||
type: "sign",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: address,
|
|
||||||
signParams: {
|
|
||||||
method: "personal_sign",
|
|
||||||
message: MESSAGE,
|
|
||||||
from: address,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await approval.show(1);
|
|
||||||
node("approve-sign-password").value = PASSWORD;
|
|
||||||
await click(node("btn-approve-sign"));
|
|
||||||
return sent.find((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site asks the address at `position` to send RECIPIENT a transaction of no
|
|
||||||
// value; resolves to the response the approval window sent the background.
|
|
||||||
async function transactFor(position) {
|
|
||||||
const address = state.wallets[0].addresses[position].address;
|
|
||||||
approvalDetails = {
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: address,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: address,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x0",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0x0",
|
|
||||||
data: "0x",
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await approval.show(1);
|
|
||||||
node("approve-tx-password").value = PASSWORD;
|
|
||||||
await click(node("btn-approve-tx"));
|
|
||||||
return sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
[
|
|
||||||
"a recovery phrase import whose scan found only the first address",
|
|
||||||
async () => {
|
|
||||||
await importPhrase([0]);
|
|
||||||
},
|
|
||||||
// The address the import stores before its scan.
|
|
||||||
{ position: 0, index: 0 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"an xprv import whose scan found only the first address",
|
|
||||||
async () => {
|
|
||||||
await importXprv([0]);
|
|
||||||
},
|
|
||||||
{ position: 0, index: 0 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"an import whose scan passed over index 3",
|
|
||||||
async () => {
|
|
||||||
await importPhrase([0, 1, 2, 4]);
|
|
||||||
},
|
|
||||||
// Address 4 on Home, derived at index 4.
|
|
||||||
{ position: 3, index: 4 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"an xprv import whose scan passed over index 3",
|
|
||||||
async () => {
|
|
||||||
await importXprv([0, 1, 2, 4]);
|
|
||||||
},
|
|
||||||
{ position: 3, index: 4 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"removing the middle one of three addresses",
|
|
||||||
async () => {
|
|
||||||
await importPhrase([0]);
|
|
||||||
await addAddress();
|
|
||||||
await addAddress();
|
|
||||||
removeAddressFromState(state, 0, 1);
|
|
||||||
},
|
|
||||||
// Address 2 on Home after the removal, derived at index 2.
|
|
||||||
{ position: 1, index: 2 },
|
|
||||||
],
|
|
||||||
])("after %s", (_name, setUp, { position, index }) => {
|
|
||||||
beforeEach(setUp);
|
|
||||||
|
|
||||||
test("the wallet lists the address derived at that index there", () => {
|
|
||||||
expect(state.wallets[0].addresses[position].address).toBe(
|
|
||||||
derived(index).address,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("export reveals that address's key", async () => {
|
|
||||||
expect(await exportedKey(position)).toBe(derived(index).privateKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a send is signed by that address", async () => {
|
|
||||||
const signed = await sendFrom(position);
|
|
||||||
expect(Transaction.from(signed).from).toBe(derived(index).address);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a site's signature is made by that address", async () => {
|
|
||||||
const response = await signFor(position);
|
|
||||||
expect(verifyMessage(getBytes(MESSAGE), response.signature)).toBe(
|
|
||||||
derived(index).address,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a site's transaction is signed by that address", async () => {
|
|
||||||
const response = await transactFor(position);
|
|
||||||
expect(Transaction.from(response.rawSignedTx).from).toBe(
|
|
||||||
derived(index).address,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The selection and the confirmation screen are saved apart, so the popup can
|
|
||||||
// reopen onto the screen with another address selected than its From line
|
|
||||||
// shows.
|
|
||||||
describe("a confirmation screen whose From is not the selected address", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await importPhrase([0, 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the send is signed by the From address shown", async () => {
|
|
||||||
const signed = await confirmSend(derived(1).address, 0);
|
|
||||||
expect(Transaction.from(signed).from).toBe(derived(1).address);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The refusal's sentence is fixed in index.html, in a box of its own that
|
|
||||||
// holds its space from the first paint, so showing it moves nothing.
|
|
||||||
test("nothing is sent when the selected wallet does not hold it", async () => {
|
|
||||||
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
|
|
||||||
expect(node("confirm-from-address-error").style.visibility).toBe(
|
|
||||||
"visible",
|
|
||||||
);
|
|
||||||
expect(node("confirm-tx-password-error").textContent).toBe("");
|
|
||||||
|
|
||||||
// Reviewing a send again draws the screen again, which hides it.
|
|
||||||
confirmTx.show({
|
|
||||||
from: derived(0).address,
|
|
||||||
to: RECIPIENT,
|
|
||||||
amount: "0.5",
|
|
||||||
token: "ETH",
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
expect(node("confirm-from-address-error").style.visibility).toBe(
|
|
||||||
"hidden",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("an address whose stored index derives a different address", () => {
|
|
||||||
function refusal() {
|
|
||||||
return (
|
|
||||||
"The key this wallet holds for " +
|
|
||||||
derived(0).address +
|
|
||||||
" belongs to a different address, so it was not used."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await importPhrase([0]);
|
|
||||||
state.wallets[0].addresses[0].index = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("export reveals nothing and says why", async () => {
|
|
||||||
expect(await exportedKey(0)).toBe("");
|
|
||||||
expect(node("export-privkey-password-error").textContent).toBe(
|
|
||||||
refusal(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nothing is sent", async () => {
|
|
||||||
expect(await sendFrom(0)).toBeUndefined();
|
|
||||||
expect(txStatus.showError).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
null,
|
|
||||||
refusal(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nothing is signed", async () => {
|
|
||||||
const response = await signFor(0);
|
|
||||||
expect(response.signature).toBeUndefined();
|
|
||||||
expect(response.error).toBe(refusal());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,8 +39,6 @@ jest.doMock("../src/popup/views/helpers", () => ({
|
|||||||
$: element,
|
$: element,
|
||||||
showView: () => {},
|
showView: () => {},
|
||||||
showFlash: () => {},
|
showFlash: () => {},
|
||||||
showError: () => {},
|
|
||||||
hideError: () => {},
|
|
||||||
goBack: () => {},
|
goBack: () => {},
|
||||||
clearViewStack: () => {},
|
clearViewStack: () => {},
|
||||||
onViewLeave: () => {},
|
onViewLeave: () => {},
|
||||||
@@ -94,7 +92,7 @@ describe.each([
|
|||||||
async function create(pageClosed) {
|
async function create(pageClosed) {
|
||||||
addWallet.init({
|
addWallet.init({
|
||||||
renderWalletList: () => {},
|
renderWalletList: () => {},
|
||||||
startRefresh: () => {},
|
doRefreshAndRender: () => {},
|
||||||
pageClosed,
|
pageClosed,
|
||||||
});
|
});
|
||||||
element("tab-" + mode).listeners.click();
|
element("tab-" + mode).listeners.click();
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
// The confirmation screen's warning for a recipient that has never sent a
|
|
||||||
// transaction (https://git.eeqj.de/sneak/AutistMask/issues/528).
|
|
||||||
//
|
|
||||||
// The node's transaction count for an address is the number of transactions
|
|
||||||
// it has sent, so an address that only ever receives, such as a savings
|
|
||||||
// address or an exchange deposit address, has a count of zero. The warning
|
|
||||||
// used to fire on that count alone. It now also needs a zero balance: ETH an
|
|
||||||
// address received stays in its balance until it sends a transaction.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
|
||||||
|
|
||||||
global.fetch = jest.fn(async () => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
const RECIPIENT = "0x" + "b".repeat(40);
|
|
||||||
|
|
||||||
// The warning types getFullWarnings() returns for RECIPIENT when the node
|
|
||||||
// reports it as a regular address with `txCount` sent transactions and a
|
|
||||||
// balance of `balance` wei.
|
|
||||||
async function warningTypes(txCount, balance) {
|
|
||||||
const provider = {
|
|
||||||
getCode: async () => "0x",
|
|
||||||
getTransactionCount: async () => txCount,
|
|
||||||
getBalance: async () => balance,
|
|
||||||
};
|
|
||||||
const warnings = await getFullWarnings(RECIPIENT, provider);
|
|
||||||
return warnings.map((w) => w.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a recipient that has received 0.5 ETH and never sent a transaction gets no warning", async () => {
|
|
||||||
expect(await warningTypes(0, 500000000000000000n)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a recipient that has never sent a transaction and holds no ETH gets the warning", async () => {
|
|
||||||
expect(await warningTypes(0, 0n)).toEqual(["new-address"]);
|
|
||||||
});
|
|
||||||
@@ -41,8 +41,6 @@ jest.mock("../src/shared/balances", () => ({
|
|||||||
refreshBalances: jest.fn(async () => {
|
refreshBalances: jest.fn(async () => {
|
||||||
mockBalanceRefreshAt.push(Date.now());
|
mockBalanceRefreshAt.push(Date.now());
|
||||||
}),
|
}),
|
||||||
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
|
|
||||||
.dropUntrackedTokens,
|
|
||||||
getProvider: jest.fn(() => ({})),
|
getProvider: jest.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
// "Allow weak passwords" in Settings is off until the user ticks it. While it
|
|
||||||
// is off, AddWallet refuses a password shorter than 12 characters. While it is
|
|
||||||
// on, AddWallet accepts any password that is not empty
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/674). These boot the real popup,
|
|
||||||
// set the checkbox in Settings as the user does, and then add a wallet.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: jest.fn(),
|
|
||||||
encryptWithPassword: jest.fn(async () => ({
|
|
||||||
salt: "salt",
|
|
||||||
nonce: "nonce",
|
|
||||||
ciphertext: "ciphertext",
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
const CHECKBOX = "settings-allow-weak-passwords";
|
|
||||||
const ERROR = "add-wallet-password-error";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Boots a popup that holds one wallet and opens Settings, where the checkbox
|
|
||||||
// starts unticked. Ticks it when `allow` is true. Then opens AddWallet and
|
|
||||||
// presses the die for a recovery phrase.
|
|
||||||
async function openAddWallet(allow) {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const checkbox = page.node(CHECKBOX);
|
|
||||||
expect(checkbox.checked).toBe(false);
|
|
||||||
if (allow) {
|
|
||||||
checkbox.checked = true;
|
|
||||||
for (const fn of checkbox.listeners.change) await fn();
|
|
||||||
expect(page.storage.read("autistmask").allowWeakPasswords).toBe(true);
|
|
||||||
}
|
|
||||||
await page.click("btn-main-add-wallet");
|
|
||||||
await page.click("btn-generate-phrase");
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types `password` into both password fields and presses Import.
|
|
||||||
async function importWith(page, password) {
|
|
||||||
page.node("add-wallet-password").value = password;
|
|
||||||
page.node("add-wallet-password-confirm").value = password;
|
|
||||||
await page.click("btn-add-wallet-confirm");
|
|
||||||
}
|
|
||||||
|
|
||||||
function storedWalletCount(page) {
|
|
||||||
return page.storage.read("autistmask").wallets.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Allow weak passwords", () => {
|
|
||||||
test("unticked: a password shorter than 12 characters is refused", async () => {
|
|
||||||
const page = await openAddWallet(false);
|
|
||||||
await importWith(page, "short");
|
|
||||||
expect(page.text(ERROR)).toBe(
|
|
||||||
"Password must be at least 12 characters.",
|
|
||||||
);
|
|
||||||
expect(storedWalletCount(page)).toBe(1);
|
|
||||||
expect(page.visibleViews()).toEqual(["add-wallet"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ticked: a password shorter than 12 characters is accepted", async () => {
|
|
||||||
const page = await openAddWallet(true);
|
|
||||||
await importWith(page, "short");
|
|
||||||
expect(page.text(ERROR)).toBe("");
|
|
||||||
expect(storedWalletCount(page)).toBe(2);
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([false, true])(
|
|
||||||
"an empty password is refused (ticked: %s)",
|
|
||||||
async (allow) => {
|
|
||||||
const page = await openAddWallet(allow);
|
|
||||||
await importWith(page, "");
|
|
||||||
expect(page.text(ERROR)).toBe("Please choose a password.");
|
|
||||||
expect(storedWalletCount(page)).toBe(1);
|
|
||||||
expect(page.visibleViews()).toEqual(["add-wallet"]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// The token names and URLs in src/shared/tokenList.js are written from
|
|
||||||
// script/lib/token-enrichment.json by script/lib/apply-enrichment.js
|
|
||||||
// (`make apply-enrichment`). This fails when the committed list differs from
|
|
||||||
// what the script writes from the committed data, and when the data or the
|
|
||||||
// script's table of websites has an address that no entry in the list matches.
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const enrichment = require("../script/lib/token-enrichment.json");
|
|
||||||
const { applyEnrichment } = require("../script/lib/apply-enrichment");
|
|
||||||
|
|
||||||
test("src/shared/tokenList.js is what make apply-enrichment writes from the committed data", () => {
|
|
||||||
const committed = fs.readFileSync(
|
|
||||||
path.join(__dirname, "..", "src/shared/tokenList.js"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(applyEnrichment(committed, enrichment)).toBe(committed);
|
|
||||||
});
|
|
||||||
@@ -84,9 +84,6 @@ function walletsHolding(token, decimals) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.trackedTokens = [];
|
state.trackedTokens = [];
|
||||||
state.wallets = [];
|
state.wallets = [];
|
||||||
// The approval screen reads the explorer's report only while "Use
|
|
||||||
// Blockscout" is on.
|
|
||||||
state.useBlockscout = true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTokenDecimals", () => {
|
describe("resolveTokenDecimals", () => {
|
||||||
|
|||||||
@@ -68,14 +68,7 @@ function swapData(amountIn, amountOutMin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function swapDetail(amountIn, amountOutMin, label) {
|
function swapDetail(amountIn, amountOutMin, label) {
|
||||||
// Sent by the address the swap pays, so the output is the user's.
|
const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER);
|
||||||
const decoded = uniswap.decode(
|
|
||||||
swapData(amountIn, amountOutMin),
|
|
||||||
ROUTER,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
RECIPIENT,
|
|
||||||
);
|
|
||||||
return decoded.details.find((d) => d.label === label);
|
return decoded.details.find((d) => d.label === label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,9 +136,7 @@ async function openTxApproval(approvedTx) {
|
|||||||
xpub: "xpub-wallet-1",
|
xpub: "xpub-wallet-1",
|
||||||
encryptedSecret: "encrypted-secret-1",
|
encryptedSecret: "encrypted-secret-1",
|
||||||
nextIndex: 1,
|
nextIndex: 1,
|
||||||
addresses: [
|
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
|
||||||
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
approval.init({});
|
approval.init({});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// The connection, transaction, signature and network switch prompts name the
|
// The connection, transaction and signature prompts name the site by its full
|
||||||
// site by its full origin, scheme and port included, not by its bare hostname
|
// origin, scheme and port included, not by its bare hostname
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
|
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
|
||||||
// or on another port, of a host the user trusts over https must not raise a
|
// or on another port, of a host the user trusts over https must not raise a
|
||||||
// prompt that reads as that trusted site.
|
// prompt that reads as that trusted site.
|
||||||
@@ -104,7 +104,6 @@ beforeEach(() => {
|
|||||||
test("the connection prompt shows the origin", async () => {
|
test("the connection prompt shows the origin", async () => {
|
||||||
await openApproval({});
|
await openApproval({});
|
||||||
expect(node("approve-origin").textContent).toBe(ORIGIN);
|
expect(node("approve-origin").textContent).toBe(ORIGIN);
|
||||||
expect(node("view-approve-site").classList.contains("hidden")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the transaction prompt shows the origin", async () => {
|
test("the transaction prompt shows the origin", async () => {
|
||||||
@@ -139,24 +138,3 @@ test("the signature prompt shows the origin", async () => {
|
|||||||
});
|
});
|
||||||
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
|
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the network switch prompt shows the origin and both networks", async () => {
|
|
||||||
await openApproval({
|
|
||||||
type: "network",
|
|
||||||
currentNetworkId: "mainnet",
|
|
||||||
requestedNetworkId: "sepolia",
|
|
||||||
});
|
|
||||||
expect(node("approve-network-origin").textContent).toBe(ORIGIN);
|
|
||||||
expect(node("approve-network-current").textContent).toBe(
|
|
||||||
"Ethereum Mainnet",
|
|
||||||
);
|
|
||||||
expect(node("approve-network-requested").textContent).toBe(
|
|
||||||
"Sepolia Testnet",
|
|
||||||
);
|
|
||||||
// Its own screen, and not the connection prompt, whose "Allow" answers
|
|
||||||
// on the same port.
|
|
||||||
expect(node("view-approve-network").classList.contains("hidden")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(node("view-approve-site").classList.contains("hidden")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
// The approval screen for a site's Sepolia token transfer while Ethereum
|
|
||||||
// Mainnet is active.
|
|
||||||
//
|
|
||||||
// The screen named the token, and scaled its amount, from the active network's
|
|
||||||
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
|
|
||||||
// an unknown token with its amount in base units, and the screens after it
|
|
||||||
// labelled the amount `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
|
|
||||||
// It now uses what the wallet knows of the tokens on the network the
|
|
||||||
// transaction is for.
|
|
||||||
//
|
|
||||||
// Its address links opened the active network's explorer
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
|
|
||||||
// explorer of the network the transaction is for.
|
|
||||||
//
|
|
||||||
// Driven through the real approval screen, password and Confirm included,
|
|
||||||
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
|
|
||||||
// The vault is mocked so that no password has to be hashed.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { Interface } = require("ethers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const { NETWORKS } = require("../src/shared/networks");
|
|
||||||
const { decryptWithPassword } = require("../src/shared/vault");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
|
|
||||||
// A well-known test phrase, and its first address.
|
|
||||||
const PHRASE = "test test test test test test test test test test test junk";
|
|
||||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
|
||||||
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
|
||||||
// A token the bundled token list does not name.
|
|
||||||
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
|
|
||||||
|
|
||||||
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
|
|
||||||
const TRANSFER_DATA = new Interface([
|
|
||||||
"function transfer(address to, uint256 amount)",
|
|
||||||
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
listeners: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: (name, fn) => {
|
|
||||||
el.listeners[name] = el.listeners[name] || [];
|
|
||||||
el.listeners[name].push(fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(id) {
|
|
||||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// One line of the decoded details: its label and what is drawn under it.
|
|
||||||
function decodedLine(label) {
|
|
||||||
return node("approve-tx-decoded-details")
|
|
||||||
.innerHTML.split('<div class="mb-2">')
|
|
||||||
.find((line) =>
|
|
||||||
line.startsWith(`<div class="text-muted">${label}</div>`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the approval screen, with Ethereum Mainnet active and FROM holding
|
|
||||||
// `tokenBalances` there, for the site's transaction sending TRANSFER_DATA to
|
|
||||||
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
|
|
||||||
// the signed transaction with a failure, which leads to the error screen.
|
|
||||||
async function showTokenTransfer(
|
|
||||||
tokenBalances = [],
|
|
||||||
chainId = NETWORKS.sepolia.chainId,
|
|
||||||
) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
|
|
||||||
return reply({
|
|
||||||
error: "The transaction could not be sent.",
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId,
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x30d40",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: TOKEN,
|
|
||||||
value: "0x0",
|
|
||||||
data: TRANSFER_DATA,
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
state.networkId = "mainnet";
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "hd",
|
|
||||||
name: "Wallet 1",
|
|
||||||
xpub: "xpub-wallet-1",
|
|
||||||
encryptedSecret: "encrypted-secret-1",
|
|
||||||
nextIndex: 1,
|
|
||||||
addresses: [
|
|
||||||
{ address: FROM, index: 0, balance: "0", tokenBalances },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.currentView = null;
|
|
||||||
txStatus.init({ doRefreshAndRender: () => {} });
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirm() {
|
|
||||||
decryptWithPassword.mockResolvedValue(PHRASE);
|
|
||||||
node("approve-tx-password").value = "any password";
|
|
||||||
await click("btn-approve-tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.networkTrackedTokens = {};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a token tracked on Sepolia only", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
state.networkTrackedTokens = {
|
|
||||||
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
|
|
||||||
await showTokenTransfer();
|
|
||||||
expect(node("approve-tx-to").innerHTML).toContain(
|
|
||||||
'<div class="font-bold mb-1">TDOL</div>',
|
|
||||||
);
|
|
||||||
expect(decodedLine("Token")).toContain(
|
|
||||||
'<div class="font-bold">TDOL</div>',
|
|
||||||
);
|
|
||||||
expect(decodedLine("Amount")).toContain(
|
|
||||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("labels the amount on the error screen that follows", async () => {
|
|
||||||
await showTokenTransfer();
|
|
||||||
await confirm();
|
|
||||||
expect(state.currentView).toBe("error-tx");
|
|
||||||
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same contract address on mainnet is another token, so what the wallet
|
|
||||||
// tracks and holds there names nothing on Sepolia.
|
|
||||||
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
|
|
||||||
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
|
|
||||||
await showTokenTransfer([
|
|
||||||
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
|
|
||||||
]);
|
|
||||||
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
|
|
||||||
expect(decodedLine("Token")).toContain(
|
|
||||||
'<div class="font-bold">Unknown token</div>',
|
|
||||||
);
|
|
||||||
expect(decodedLine("Amount")).toContain(
|
|
||||||
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The From and Contract lines, and the decoded Token and Recipient lines.
|
|
||||||
test("every address links to the Sepolia explorer", async () => {
|
|
||||||
await showTokenTransfer();
|
|
||||||
const sepolia = NETWORKS.sepolia.explorerUrl;
|
|
||||||
expect(node("approve-tx-from").innerHTML).toContain(
|
|
||||||
`href="${sepolia}/address/${FROM}"`,
|
|
||||||
);
|
|
||||||
expect(node("approve-tx-to").innerHTML).toContain(
|
|
||||||
`href="${sepolia}/address/${TOKEN}"`,
|
|
||||||
);
|
|
||||||
expect(decodedLine("Token")).toContain(
|
|
||||||
`href="${sepolia}/address/${TOKEN}"`,
|
|
||||||
);
|
|
||||||
expect(decodedLine("Recipient")).toContain(
|
|
||||||
`href="${sepolia}/address/${RECIPIENT}"`,
|
|
||||||
);
|
|
||||||
for (const id of [
|
|
||||||
"approve-tx-from",
|
|
||||||
"approve-tx-to",
|
|
||||||
"approve-tx-decoded-details",
|
|
||||||
]) {
|
|
||||||
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chain id 1337 names no network here, so it has no explorer to link to.
|
|
||||||
test("a transaction on a chain id that names no network is shown with no address links", async () => {
|
|
||||||
await showTokenTransfer([], "0x539");
|
|
||||||
expect(state.currentView).toBe("approve-tx");
|
|
||||||
expect(node("approve-tx-network").textContent).toBe(
|
|
||||||
"Unknown network (chain id 1337)",
|
|
||||||
);
|
|
||||||
expect(node("approve-tx-from").innerHTML).toContain(FROM);
|
|
||||||
for (const id of [
|
|
||||||
"approve-tx-from",
|
|
||||||
"approve-tx-to",
|
|
||||||
"approve-tx-decoded-details",
|
|
||||||
]) {
|
|
||||||
expect(node(id).innerHTML).not.toContain("href=");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
// requesting page or the RPC node can say turns it into an approval that
|
// requesting page or the RPC node can say turns it into an approval that
|
||||||
// should never have been raised.
|
// should never have been raised.
|
||||||
|
|
||||||
const { Network, Transaction, Wallet, toQuantity } = require("ethers");
|
const { Network, Wallet } = require("ethers");
|
||||||
const {
|
const {
|
||||||
prepareApprovalTx,
|
prepareApprovalTx,
|
||||||
serializeApprovedTx,
|
serializeApprovedTx,
|
||||||
@@ -138,49 +138,6 @@ describe("prepareApprovalTx", () => {
|
|||||||
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
||||||
});
|
});
|
||||||
|
|
||||||
// `gas` is the JSON-RPC name for the gas limit and the one sites send.
|
|
||||||
// The node is still asked for its estimate, with the site's gas limit,
|
|
||||||
// because the estimate is the check that the transaction does not revert.
|
|
||||||
// It estimates 21000; the site's 200000 is what is shown and what is
|
|
||||||
// signed.
|
|
||||||
test("uses the gas limit a site sends as gas", async () => {
|
|
||||||
const estimateGas = jest.fn(async () => 21000n);
|
|
||||||
const approved = await prepareApprovalTx(
|
|
||||||
providerWith({ estimateGas }),
|
|
||||||
signer.address,
|
|
||||||
{ ...TX_PARAMS, gas: "0x30d40" },
|
|
||||||
);
|
|
||||||
expect(estimateGas).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ gasLimit: 200000n }),
|
|
||||||
);
|
|
||||||
expect(approved.gasLimit).toBe("0x30d40");
|
|
||||||
const signed = Transaction.from(
|
|
||||||
await signer.signTransaction({ ...approved }),
|
|
||||||
);
|
|
||||||
expect(signed.gasLimit).toBe(200000n);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts gas and gasLimit that name the same number", async () => {
|
|
||||||
const approved = await prepareApprovalTx(
|
|
||||||
providerWith(),
|
|
||||||
signer.address,
|
|
||||||
{ ...TX_PARAMS, gas: "0x30d40", gasLimit: 200000 },
|
|
||||||
);
|
|
||||||
expect(approved.gasLimit).toBe("0x30d40");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refuses gas and gasLimit that disagree", async () => {
|
|
||||||
await expect(
|
|
||||||
prepareApprovalTx(providerWith(), signer.address, {
|
|
||||||
...TX_PARAMS,
|
|
||||||
gas: "0x30d40",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"The site asked for two different gas limits for this transaction.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("carries an access list the request asked for", async () => {
|
test("carries an access list the request asked for", async () => {
|
||||||
const approved = await prepareApprovalTx(
|
const approved = await prepareApprovalTx(
|
||||||
providerWith(),
|
providerWith(),
|
||||||
@@ -255,15 +212,6 @@ describe("prepareApprovalTx", () => {
|
|||||||
).rejects.toThrow(/gas limit no network this wallet supports/);
|
).rejects.toThrow(/gas limit no network this wallet supports/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refuses a gas limit the site sent above the ceiling", async () => {
|
|
||||||
await expect(
|
|
||||||
prepareApprovalTx(providerWith(), signer.address, {
|
|
||||||
...TX_PARAMS,
|
|
||||||
gas: toQuantity(MAX_GAS_LIMIT + 1n),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/gas limit no network this wallet supports/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The combined bound at population: a gas limit and a fee that are each
|
// The combined bound at population: a gas limit and a fee that are each
|
||||||
// under their own ceiling but multiply to thousands of ETH is refused
|
// under their own ceiling but multiply to thousands of ETH is refused
|
||||||
// before the approval window opens, so the user is never shown a
|
// before the approval window opens, so the user is never shown a
|
||||||
@@ -303,22 +251,6 @@ describe("prepareApprovalTx", () => {
|
|||||||
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails back a reverting request that sends its own gas limit", async () => {
|
|
||||||
const reverting = providerWith({
|
|
||||||
estimateGas: async () => {
|
|
||||||
throw new Error("execution reverted: ERC20: transfer amount");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
prepareApprovalTx(reverting, signer.address, {
|
|
||||||
...TX_PARAMS,
|
|
||||||
gas: "0x30d40",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(
|
|
||||||
/^The transaction could not be prepared: execution reverted/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Without a bound, an unreachable node leaves the page's promise pending
|
// Without a bound, an unreachable node leaves the page's promise pending
|
||||||
// with nothing on screen to explain it.
|
// with nothing on screen to explain it.
|
||||||
test("gives up on a node that never answers", async () => {
|
test("gives up on a node that never answers", async () => {
|
||||||
@@ -341,28 +273,6 @@ describe("prepareApprovalTx", () => {
|
|||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The estimate of a request carrying the site's gas limit is a call of its
|
|
||||||
// own, made before populating, and is bounded the same way.
|
|
||||||
test("gives up on a node that never answers the estimate of a request that sends its own gas limit", async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const hanging = providerWith({
|
|
||||||
estimateGas: () => new Promise(() => {}),
|
|
||||||
});
|
|
||||||
const pending = prepareApprovalTx(hanging, signer.address, {
|
|
||||||
...TX_PARAMS,
|
|
||||||
gas: "0x30d40",
|
|
||||||
});
|
|
||||||
const settled = expect(pending).rejects.toThrow(
|
|
||||||
/did not answer in time/,
|
|
||||||
);
|
|
||||||
await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
|
|
||||||
await settled;
|
|
||||||
} finally {
|
|
||||||
jest.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("serializeApprovedTx", () => {
|
describe("serializeApprovedTx", () => {
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
// While "Use Blockscout" is off, the approval screens, and the screens that
|
|
||||||
// follow TxApproval, take a token's name and scale from the bundled list and
|
|
||||||
// the tracked tokens alone (https://git.eeqj.de/sneak/AutistMask/issues/668).
|
|
||||||
// A token missing from both gets a line under it. The line says Blockscout's
|
|
||||||
// report of the user's balances is not used to name the token. While the
|
|
||||||
// setting is on, Blockscout's report of the user's holding is a source too, as
|
|
||||||
// before.
|
|
||||||
//
|
|
||||||
// Booted through the real popup entry point, as the approval window or as a
|
|
||||||
// popup reopened onto SuccessTx, WaitTx or ErrorTx. The vault is mocked so that
|
|
||||||
// no password has to be hashed: the stored secret decrypts to the test key
|
|
||||||
// below.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: jest.fn(),
|
|
||||||
encryptWithPassword: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { Interface, Wallet } = require("ethers");
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
// A published test key, never used for real funds.
|
|
||||||
const SIGNER_KEY =
|
|
||||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
||||||
const signer = new Wallet(SIGNER_KEY);
|
|
||||||
const ORIGIN = "https://dapp.example";
|
|
||||||
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
|
||||||
const TX_HASH = "0x" + "ab".repeat(32);
|
|
||||||
// Outside the bundled list, and not tracked.
|
|
||||||
const TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
|
||||||
// In the bundled list, at 6 decimals.
|
|
||||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
||||||
|
|
||||||
const LINE =
|
|
||||||
'Blockscout\'s report of your balances is not used to name this token, because "Use Blockscout" is off in Settings.';
|
|
||||||
|
|
||||||
// TOKEN as Blockscout reports the user's holding of it.
|
|
||||||
const TOKEN_ROW = {
|
|
||||||
address: TOKEN,
|
|
||||||
name: "Novel",
|
|
||||||
symbol: "NOVEL",
|
|
||||||
decimals: "6",
|
|
||||||
balance: "5000.0",
|
|
||||||
holders: 5000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// A transfer of 5,000 of a 6-decimal token to RECIPIENT.
|
|
||||||
const TRANSFER_DATA = new Interface([
|
|
||||||
"function transfer(address to, uint256 amount)",
|
|
||||||
]).encodeFunctionData("transfer", [RECIPIENT, 5000000000n]);
|
|
||||||
|
|
||||||
// An EIP-2612 permit letting RECIPIENT take 5,000 of TOKEN.
|
|
||||||
const PERMIT = {
|
|
||||||
types: {
|
|
||||||
EIP712Domain: [
|
|
||||||
{ name: "name", type: "string" },
|
|
||||||
{ name: "chainId", type: "uint256" },
|
|
||||||
{ name: "verifyingContract", type: "address" },
|
|
||||||
],
|
|
||||||
Permit: [
|
|
||||||
{ name: "owner", type: "address" },
|
|
||||||
{ name: "spender", type: "address" },
|
|
||||||
{ name: "value", type: "uint256" },
|
|
||||||
{ name: "nonce", type: "uint256" },
|
|
||||||
{ name: "deadline", type: "uint256" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
primaryType: "Permit",
|
|
||||||
domain: { name: "Novel", chainId: 1, verifyingContract: TOKEN },
|
|
||||||
message: {
|
|
||||||
owner: signer.address,
|
|
||||||
spender: RECIPIENT,
|
|
||||||
value: "5000000000",
|
|
||||||
nonce: "0",
|
|
||||||
deadline: "1790000000",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// The stored profile: the user's one address holds `tokenBalances`.
|
|
||||||
function profile(useBlockscout, tokenBalances, extra) {
|
|
||||||
return unversionedValidProfile({
|
|
||||||
useBlockscout,
|
|
||||||
wallets: [
|
|
||||||
{
|
|
||||||
type: "key",
|
|
||||||
name: "Wallet 1",
|
|
||||||
encryptedSecret: "encrypted-key",
|
|
||||||
addresses: [
|
|
||||||
{
|
|
||||||
address: signer.address,
|
|
||||||
balance: "1.0",
|
|
||||||
tokenBalances,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeAddress: signer.address,
|
|
||||||
allowedSites: { [signer.address]: [ORIGIN] },
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The approval window for the site's request. The request is a transfer sent to
|
|
||||||
// `token`, or a signature request when `signParams` is given. The background
|
|
||||||
// answers the signed transaction with `txResponse`.
|
|
||||||
function bootApproval(stored, { token = TOKEN, signParams, txResponse } = {}) {
|
|
||||||
return bootPopup(stored, {
|
|
||||||
search: "?approval=approval-1",
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
|
||||||
reply({
|
|
||||||
type: signParams ? "sign" : "tx",
|
|
||||||
origin: ORIGIN,
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: signer.address,
|
|
||||||
signParams,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: signer.address,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x30d40",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: token,
|
|
||||||
value: "0x0",
|
|
||||||
data: TRANSFER_DATA,
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
|
||||||
reply(txResponse);
|
|
||||||
} else {
|
|
||||||
reply(undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// The page logs its failed RPC reads; the stub node in popupBoot.js
|
|
||||||
// answers none.
|
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TxApproval", () => {
|
|
||||||
test("has the line under a token it cannot name while Use Blockscout is off", async () => {
|
|
||||||
const page = await bootApproval(profile(false, []));
|
|
||||||
const details = page.node("approve-tx-decoded-details").innerHTML;
|
|
||||||
expect(details).toContain("Unknown token");
|
|
||||||
expect(details).toContain(LINE);
|
|
||||||
expect(details).toContain("5000000000 base units (decimals unknown)");
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
// The bundled list names USDC, so there is nothing to say.
|
|
||||||
const bundled = await bootApproval(profile(false, []), {
|
|
||||||
token: USDC,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
bundled.node("approve-tx-decoded-details").innerHTML,
|
|
||||||
).not.toContain(LINE);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
const on = await bootApproval(profile(true, [TOKEN_ROW]));
|
|
||||||
const named = on.node("approve-tx-decoded-details").innerHTML;
|
|
||||||
expect(named).toContain("5000.0000 NOVEL");
|
|
||||||
expect(named).not.toContain(LINE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("names nothing from a Blockscout entry still in the balances while Use Blockscout is off", async () => {
|
|
||||||
const page = await bootApproval(profile(false, [TOKEN_ROW]));
|
|
||||||
const details = page.node("approve-tx-decoded-details").innerHTML;
|
|
||||||
expect(details).not.toContain("NOVEL");
|
|
||||||
expect(details).toContain("Unknown token");
|
|
||||||
expect(details).toContain(LINE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["WaitTx", { txHash: TX_HASH }, "wait-tx"],
|
|
||||||
[
|
|
||||||
"ErrorTx",
|
|
||||||
{ error: "The transaction could not be sent.", retryable: false },
|
|
||||||
"error-tx",
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
"%s after it says so under the amount while Use Blockscout is off",
|
|
||||||
async (_name, txResponse, view) => {
|
|
||||||
const page = await bootApproval(profile(false, []), {
|
|
||||||
txResponse,
|
|
||||||
});
|
|
||||||
const { decryptWithPassword } = require("../src/shared/vault");
|
|
||||||
decryptWithPassword.mockResolvedValue(SIGNER_KEY);
|
|
||||||
page.node("approve-tx-password").value = "any password";
|
|
||||||
await page.click("btn-approve-tx");
|
|
||||||
expect(page.visibleViews()).toEqual([view]);
|
|
||||||
expect(page.node(view + "-token-note").innerHTML).toBe(
|
|
||||||
`<div class="text-xs text-muted">${LINE}</div>`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SuccessTx says so under the decoded token while Use Blockscout is off", async () => {
|
|
||||||
const page = await bootPopup(
|
|
||||||
profile(false, [], {
|
|
||||||
currentView: "success-tx",
|
|
||||||
viewStack: ["main"],
|
|
||||||
viewData: {
|
|
||||||
amount: "5000000000 base units (decimals unknown)",
|
|
||||||
symbol: "?",
|
|
||||||
to: RECIPIENT,
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 21000000,
|
|
||||||
chainId: "0x1",
|
|
||||||
decoded: {
|
|
||||||
name: "Token Transfer",
|
|
||||||
description: "Transfer ERC-20 token",
|
|
||||||
details: [
|
|
||||||
{
|
|
||||||
label: "Token",
|
|
||||||
value: TOKEN,
|
|
||||||
address: TOKEN,
|
|
||||||
isToken: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(page.visibleViews()).toEqual(["success-tx"]);
|
|
||||||
const decoded = page.node("success-tx-decoded").innerHTML;
|
|
||||||
expect(decoded).toContain("Unknown token");
|
|
||||||
expect(decoded).toContain(LINE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
[
|
|
||||||
"wait-tx",
|
|
||||||
{
|
|
||||||
pendingWait: {
|
|
||||||
hash: TX_HASH,
|
|
||||||
txInfo: {
|
|
||||||
to: RECIPIENT,
|
|
||||||
amount: "1",
|
|
||||||
token: 42,
|
|
||||||
chainId: "0x1",
|
|
||||||
},
|
|
||||||
broadcastTime: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"error-tx",
|
|
||||||
{
|
|
||||||
amount: "1",
|
|
||||||
symbol: "?",
|
|
||||||
token: 42,
|
|
||||||
to: RECIPIENT,
|
|
||||||
hash: TX_HASH,
|
|
||||||
message: "The transaction failed.",
|
|
||||||
chainId: "0x1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
"a popup reopened onto %s shows no line for a stored token that is not text",
|
|
||||||
async (view, viewData) => {
|
|
||||||
const page = await bootPopup(
|
|
||||||
profile(false, [], {
|
|
||||||
currentView: view,
|
|
||||||
viewStack: ["main"],
|
|
||||||
viewData,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
expect(page.visibleViews()).toEqual([view]);
|
|
||||||
expect(page.node(view + "-token-note").innerHTML).toBe("");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("SignApproval says so under a permit's token while Use Blockscout is off", async () => {
|
|
||||||
const signParams = {
|
|
||||||
method: "eth_signTypedData_v4",
|
|
||||||
typedData: JSON.stringify(PERMIT),
|
|
||||||
from: signer.address,
|
|
||||||
};
|
|
||||||
const page = await bootApproval(profile(false, []), { signParams });
|
|
||||||
const message = page.node("approve-sign-message").innerHTML;
|
|
||||||
expect(message).toContain("Unknown token");
|
|
||||||
expect(message).toContain(LINE);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
const on = await bootApproval(profile(true, [TOKEN_ROW]), { signParams });
|
|
||||||
const named = on.node("approve-sign-message").innerHTML;
|
|
||||||
expect(named).toContain("5000.0000 NOVEL");
|
|
||||||
expect(named).not.toContain(LINE);
|
|
||||||
});
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
// The USD value on the approval screen's Value and Network fee lines, for a
|
|
||||||
// site's ETH send.
|
|
||||||
//
|
|
||||||
// It was worked out from the four-decimal figure the line shows, at the price
|
|
||||||
// on the active network, so the same value read a few cents different here than
|
|
||||||
// on the confirmation screen, which prices the exact amount at the price on the
|
|
||||||
// transaction's own network, and a transaction on another network than the
|
|
||||||
// active one was priced at the active one's rate, its fee too
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/614).
|
|
||||||
//
|
|
||||||
// Driven through the real approval screen, against a minimal DOM stub in the
|
|
||||||
// shape tests/approvalTokenNetwork.test.js uses.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const { NETWORKS } = require("../src/shared/networks");
|
|
||||||
const { prices, clearPrices } = require("../src/shared/prices");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
|
||||||
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
listeners: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: (name, fn) => {
|
|
||||||
el.listeners[name] = el.listeners[name] || [];
|
|
||||||
el.listeners[name].push(fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the approval screen, with the network `networkId` active, for the
|
|
||||||
// site's transaction sending 1.23414 ETH to RECIPIENT on the chain `chainId`.
|
|
||||||
async function showEthSend(chainId, networkId) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId,
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "1234140000000000000",
|
|
||||||
data: "0x",
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
state.networkId = networkId;
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "hd",
|
|
||||||
name: "Wallet 1",
|
|
||||||
xpub: "xpub-wallet-1",
|
|
||||||
encryptedSecret: "encrypted-secret-1",
|
|
||||||
nextIndex: 1,
|
|
||||||
addresses: [{ address: FROM, index: 0, balance: "0" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.networkTrackedTokens = {};
|
|
||||||
state.currentView = null;
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
prices.ETH = 2000;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
clearPrices();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1.23414 ETH reads 1.2341 ETH. At $2,000 the exact value is $2,468.28, the
|
|
||||||
// figure the confirmation screen shows for it; the shown figure would be
|
|
||||||
// $2,468.20.
|
|
||||||
test.each([
|
|
||||||
[
|
|
||||||
"a mainnet transaction is priced from its exact value",
|
|
||||||
NETWORKS.mainnet.chainId,
|
|
||||||
"mainnet",
|
|
||||||
"1.2341 ETH ($2,468.28)",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"a mainnet transaction shown with Sepolia active is priced at the mainnet price",
|
|
||||||
NETWORKS.mainnet.chainId,
|
|
||||||
"sepolia",
|
|
||||||
"1.2341 ETH ($2,468.28)",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"a Sepolia transaction shown with mainnet active has no USD value",
|
|
||||||
NETWORKS.sepolia.chainId,
|
|
||||||
"mainnet",
|
|
||||||
"1.2341 SepoliaETH",
|
|
||||||
],
|
|
||||||
// Chain id 1337 names no network here, so nothing knows its price.
|
|
||||||
[
|
|
||||||
"a transaction on a chain id that names no network has no USD value",
|
|
||||||
"0x539",
|
|
||||||
"mainnet",
|
|
||||||
"1.2341 ETH",
|
|
||||||
],
|
|
||||||
])("%s", async (_label, chainId, networkId, expected) => {
|
|
||||||
await showEthSend(chainId, networkId);
|
|
||||||
expect(state.currentView).toBe("approve-tx");
|
|
||||||
expect(node("approve-tx-value").textContent).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The Network fee line is priced on the transaction's network too. 21000 gas at
|
|
||||||
// up to 2 gwei is 0.000042 ETH, $0.08 at $2,000.
|
|
||||||
test.each([
|
|
||||||
[
|
|
||||||
"the fee of a mainnet transaction shown with Sepolia active is priced at the mainnet price",
|
|
||||||
NETWORKS.mainnet.chainId,
|
|
||||||
"sepolia",
|
|
||||||
"0.00004 ETH ($0.08)",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"the fee of a Sepolia transaction shown with mainnet active has no USD value",
|
|
||||||
NETWORKS.sepolia.chainId,
|
|
||||||
"mainnet",
|
|
||||||
"0.00004 SepoliaETH",
|
|
||||||
],
|
|
||||||
])("%s", async (_label, chainId, networkId, expected) => {
|
|
||||||
await showEthSend(chainId, networkId);
|
|
||||||
expect(state.currentView).toBe("approve-tx");
|
|
||||||
expect(node("approve-tx-fee").textContent).toBe(expected);
|
|
||||||
});
|
|
||||||
@@ -58,6 +58,7 @@ const TX_PARAMS = {
|
|||||||
to: RECIPIENT,
|
to: RECIPIENT,
|
||||||
value: "0x2386f26fc10000",
|
value: "0x2386f26fc10000",
|
||||||
data: "0xdeadbeef",
|
data: "0xdeadbeef",
|
||||||
|
gas: "0x5208",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The values populateTransaction() fills in when the dApp fixed none of them.
|
// The values populateTransaction() fills in when the dApp fixed none of them.
|
||||||
@@ -1454,11 +1455,7 @@ describe("background population to popup signing to verification", () => {
|
|||||||
|
|
||||||
// What the popup does with it: signs it as given, populating nothing.
|
// What the popup does with it: signs it as given, populating nothing.
|
||||||
async function popupSigns(approvedTx) {
|
async function popupSigns(approvedTx) {
|
||||||
const localSigner = getSignerForAddress(
|
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
||||||
walletData,
|
|
||||||
{ address: signer.address },
|
|
||||||
SIGNER_KEY,
|
|
||||||
);
|
|
||||||
return localSigner.signTransaction({ ...approvedTx });
|
return localSigner.signTransaction({ ...approvedTx });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1553,11 +1550,7 @@ describe("background population to popup signing to verification", () => {
|
|||||||
// address the user has switched to.
|
// address the user has switched to.
|
||||||
test("the approved from stops the popup signing with another key", async () => {
|
test("the approved from stops the popup signing with another key", async () => {
|
||||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||||
const otherSigner = getSignerForAddress(
|
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
|
||||||
walletData,
|
|
||||||
{ address: other.address },
|
|
||||||
OTHER_KEY,
|
|
||||||
);
|
|
||||||
await expect(
|
await expect(
|
||||||
otherSigner.signTransaction({ ...approvedTx }),
|
otherSigner.signTransaction({ ...approvedTx }),
|
||||||
).rejects.toThrow(/from address mismatch/);
|
).rejects.toThrow(/from address mismatch/);
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
// The approval window shows no screen until the background has described the
|
|
||||||
// approval it answers (https://git.eeqj.de/sneak/AutistMask/issues/408). The
|
|
||||||
// connection prompt's "Allow" and the network switch prompt's "Switch" answer
|
|
||||||
// on the same port, so a window that showed the connection prompt while it
|
|
||||||
// waited could approve a network switch.
|
|
||||||
//
|
|
||||||
// Booted through the real popup entry point, which is where the connection
|
|
||||||
// prompt used to be put up before the background had answered.
|
|
||||||
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
settle,
|
|
||||||
unversionedValidProfile,
|
|
||||||
ADDRESS,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
afterEach(cleanupPopup);
|
|
||||||
|
|
||||||
test("the approval window shows no screen until the background describes the approval", async () => {
|
|
||||||
// The background's answer, held until the test gives it.
|
|
||||||
let answer = null;
|
|
||||||
const env = await bootPopup(unversionedValidProfile(), {
|
|
||||||
search: "?approval=approval-1",
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(answer).not.toBeNull();
|
|
||||||
// No screen at all, the connection prompt included.
|
|
||||||
expect(env.visibleViews()).toEqual([]);
|
|
||||||
|
|
||||||
answer({
|
|
||||||
type: "network",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
currentNetworkId: "mainnet",
|
|
||||||
requestedNetworkId: "sepolia",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(env.visibleViews()).toEqual(["approve-network"]);
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The background opens the approval window as wide as the page, but the
|
|
||||||
// browser counts the window's frame in that width, and a screen taller than
|
|
||||||
// the window takes a vertical scrollbar out of it too
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/518). The browser suites run
|
|
||||||
// without frames, and only the Firefox suite's sign prompt at a zoom other
|
|
||||||
// than 100%, so the widening is checked here too.
|
|
||||||
//
|
|
||||||
// A window 396 screen pixels wide whose page is drawn at `drawn` screen pixels
|
|
||||||
// to the page pixel. Widening it gives the page its new width, less the 15
|
|
||||||
// screen pixels of the scrollbar the approval screen has, and then calls the
|
|
||||||
// page's resize listeners, each of which asked to be called once.
|
|
||||||
function windowsStub(type, drawn) {
|
|
||||||
const updates = [];
|
|
||||||
return {
|
|
||||||
updates,
|
|
||||||
getCurrent: (done) => done({ id: 7, type, width: 396 }),
|
|
||||||
update: (id, info, done) => {
|
|
||||||
updates.push({ id, info });
|
|
||||||
done({});
|
|
||||||
globalThis.document.documentElement.clientWidth = Math.floor(
|
|
||||||
(info.width - 15) / drawn,
|
|
||||||
);
|
|
||||||
const resized = globalThis.window.listeners.resize || [];
|
|
||||||
globalThis.window.listeners.resize = [];
|
|
||||||
for (const fn of resized) fn();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The approval page's own tab is at `zoom`. Asked without a tab id, Firefox
|
|
||||||
// answers for the window focused last, here a site's tab at 100%.
|
|
||||||
function tabsStub(zoom) {
|
|
||||||
return {
|
|
||||||
getCurrent: (done) => done({ id: 11 }),
|
|
||||||
getZoom: (...args) => {
|
|
||||||
const done = args.pop();
|
|
||||||
done(args[0] === 11 ? zoom : 1);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// An approval window at `zoom`, `clientWidth` page pixels wide before its
|
|
||||||
// screen is up, whose background holds its answer until the test gives it.
|
|
||||||
async function bootZoomedApprovalWindow(windows, zoom, clientWidth) {
|
|
||||||
let answer = null;
|
|
||||||
const env = await bootPopup(unversionedValidProfile(), {
|
|
||||||
search: "?approval=approval-1",
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
windows,
|
|
||||||
tabs: tabsStub(zoom),
|
|
||||||
documentElement: { scrollWidth: 396, clientWidth },
|
|
||||||
});
|
|
||||||
return { env, answer };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("at 150% zoom the approval window widens by what its screen lacks, scrollbar included", async () => {
|
|
||||||
const windows = windowsStub("popup", 1.5);
|
|
||||||
// At 150% a window 396 screen pixels wide shows 264 page pixels.
|
|
||||||
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
|
|
||||||
|
|
||||||
// Not before there is a screen to fit.
|
|
||||||
expect(windows.updates).toEqual([]);
|
|
||||||
|
|
||||||
// The screen is taller than the window, so once it is up a vertical
|
|
||||||
// scrollbar takes 15 screen pixels, 10 page pixels at 150%.
|
|
||||||
env.document.documentElement.clientWidth = 254;
|
|
||||||
answer({
|
|
||||||
type: "network",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
currentNetworkId: "mainnet",
|
|
||||||
requestedNetworkId: "sepolia",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// 396 - 254 = 142 page pixels short, which at 150% is 213 screen pixels.
|
|
||||||
// At the 100% of the site's tab it would be 142, and the screen would
|
|
||||||
// still not fit.
|
|
||||||
expect(env.visibleViews()).toEqual(["approve-network"]);
|
|
||||||
expect(windows.updates).toEqual([{ id: 7, info: { width: 609 } }]);
|
|
||||||
expect(env.document.documentElement.clientWidth).toBe(396);
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("at 170% the approval window is widened again while its screen still falls short", async () => {
|
|
||||||
// Firefox reports 170% as 1.7 but draws a page pixel about 1.714 screen
|
|
||||||
// pixels wide, so a window 396 wide shows 231 page pixels.
|
|
||||||
const windows = windowsStub("popup", 1.714);
|
|
||||||
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.7, 231);
|
|
||||||
|
|
||||||
// Less the scrollbar once the screen is up.
|
|
||||||
env.document.documentElement.clientWidth = 222;
|
|
||||||
answer({
|
|
||||||
type: "network",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
currentNetworkId: "mainnet",
|
|
||||||
requestedNetworkId: "sepolia",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// 174 page pixels short, so 296 screen pixels wider at 1.7. That shows 394
|
|
||||||
// page pixels, still 2 short, so 4 screen pixels wider again.
|
|
||||||
expect(windows.updates).toEqual([
|
|
||||||
{ id: 7, info: { width: 692 } },
|
|
||||||
{ id: 7, info: { width: 696 } },
|
|
||||||
]);
|
|
||||||
const page = env.document.documentElement;
|
|
||||||
expect(page.scrollWidth).toBeLessThanOrEqual(page.clientWidth);
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a connection prompt in the toolbar popup leaves the browser window alone", async () => {
|
|
||||||
const windows = windowsStub("normal", 1.5);
|
|
||||||
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
|
|
||||||
|
|
||||||
answer({ origin: "https://dapp.example", isPhishingDomain: false });
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(env.visibleViews()).toEqual(["approve-site"]);
|
|
||||||
expect(windows.updates).toEqual([]);
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Each row holds a transaction or signature approval as the background
|
|
||||||
// describes it, plus the element ids of the screen that shows it. `refusal` is
|
|
||||||
// the message the background refuses a Reject with.
|
|
||||||
const TX_AND_SIGN_APPROVALS = [
|
|
||||||
{
|
|
||||||
kind: "transaction",
|
|
||||||
view: "approve-tx",
|
|
||||||
reject: "btn-reject-tx",
|
|
||||||
error: "approve-tx-error",
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
refusal: "This transaction is already being sent.",
|
|
||||||
approval: {
|
|
||||||
type: "tx",
|
|
||||||
approvedFrom: ADDRESS,
|
|
||||||
approvedTx: {
|
|
||||||
chainId: 1,
|
|
||||||
to: ADDRESS,
|
|
||||||
value: "0",
|
|
||||||
data: "0x",
|
|
||||||
gasLimit: "21000",
|
|
||||||
maxFeePerGas: "1000000000",
|
|
||||||
maxPriorityFeePerGas: "1000000000",
|
|
||||||
nonce: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "signature",
|
|
||||||
view: "approve-sign",
|
|
||||||
reject: "btn-reject-sign",
|
|
||||||
error: "approve-sign-error",
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
||||||
refusal: "This request is already being signed.",
|
|
||||||
approval: {
|
|
||||||
type: "sign",
|
|
||||||
approvedFrom: ADDRESS,
|
|
||||||
signParams: {
|
|
||||||
method: "personal_sign",
|
|
||||||
message: "0x48656c6c6f",
|
|
||||||
from: ADDRESS,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// A closed transaction or signature window leaves its request pending
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
|
|
||||||
// window only once the background has the rejection. A message sent as the
|
|
||||||
// window closes can be lost, and the request would stay pending although the
|
|
||||||
// user rejected it.
|
|
||||||
test.each(TX_AND_SIGN_APPROVALS)(
|
|
||||||
"Reject on a $kind approval closes the window only once the background has answered",
|
|
||||||
async ({ view, reject, type, approval }) => {
|
|
||||||
let describeApproval = null;
|
|
||||||
let answerReject = null;
|
|
||||||
const sent = [];
|
|
||||||
const env = await bootPopup(unversionedValidProfile(), {
|
|
||||||
search: "?approval=approval-1",
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
|
||||||
describeApproval = reply;
|
|
||||||
}
|
|
||||||
if (msg.type === type) {
|
|
||||||
sent.push(msg);
|
|
||||||
answerReject = reply;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
describeApproval({
|
|
||||||
...approval,
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
expect(env.visibleViews()).toEqual([view]);
|
|
||||||
const close = jest.fn();
|
|
||||||
globalThis.window.close = close;
|
|
||||||
|
|
||||||
const clicked = env.click(reject);
|
|
||||||
await settle();
|
|
||||||
expect(sent).toEqual([{ type, id: "approval-1", approved: false }]);
|
|
||||||
expect(close).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
answerReject(null);
|
|
||||||
await clicked;
|
|
||||||
expect(close).toHaveBeenCalledTimes(1);
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// After Confirm or Sign, the background refuses a Reject while the transaction
|
|
||||||
// is being sent or the signature checked. The request is not rejected then, so
|
|
||||||
// the window stays open and shows the background's message.
|
|
||||||
test.each(TX_AND_SIGN_APPROVALS)(
|
|
||||||
"Reject on a $kind approval that the background refuses shows the background's message and keeps the window open",
|
|
||||||
async ({ view, reject, error, type, refusal, approval }) => {
|
|
||||||
let describeApproval = null;
|
|
||||||
let answerReject = null;
|
|
||||||
const env = await bootPopup(unversionedValidProfile(), {
|
|
||||||
search: "?approval=approval-1",
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
|
||||||
describeApproval = reply;
|
|
||||||
}
|
|
||||||
if (msg.type === type) answerReject = reply;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
describeApproval({
|
|
||||||
...approval,
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
const close = jest.fn();
|
|
||||||
globalThis.window.close = close;
|
|
||||||
|
|
||||||
const clicked = env.click(reject);
|
|
||||||
await settle();
|
|
||||||
answerReject({ error: refusal, retryable: false });
|
|
||||||
await clicked;
|
|
||||||
expect(close).not.toHaveBeenCalled();
|
|
||||||
expect(env.visibleViews()).toEqual([view]);
|
|
||||||
expect(env.text(error)).toBe(refusal);
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -154,14 +154,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
|||||||
|
|
||||||
test("Back onto the transaction detail renders it", () => {
|
test("Back onto the transaction detail renders it", () => {
|
||||||
reopenedOn("settings", ["main", "transaction"], {
|
reopenedOn("settings", ["main", "transaction"], {
|
||||||
viewData: {
|
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
|
||||||
tx: {
|
|
||||||
hash: "0xdead",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
chainId: "0x1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
goBack();
|
goBack();
|
||||||
expect(calls).toEqual(["transactionDetail"]);
|
expect(calls).toEqual(["transactionDetail"]);
|
||||||
|
|||||||
@@ -241,11 +241,10 @@ function loadBackground(options) {
|
|||||||
let connectListener = null;
|
let connectListener = null;
|
||||||
const created = [];
|
const created = [];
|
||||||
const removed = [];
|
const removed = [];
|
||||||
// Every URL the background put on the browser action. When a test gives
|
// Every URL the background put on the browser action. A site approval
|
||||||
// the URL the action holds before the worker starts, that URL comes
|
// raised through action.openPopup() opens no window at all, so this is
|
||||||
// first. A site approval raised through action.openPopup() opens no
|
// the only place its id appears.
|
||||||
// window at all, so this is the only place its id appears.
|
const actionPopups = [];
|
||||||
const actionPopups = opts.toolbarPopup ? [opts.toolbarPopup] : [];
|
|
||||||
const openPopup = jest.fn(() => Promise.resolve());
|
const openPopup = jest.fn(() => Promise.resolve());
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
@@ -265,7 +264,6 @@ function loadBackground(options) {
|
|||||||
connectListener = fn;
|
connectListener = fn;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getPlatformInfo: jest.fn((cb) => cb({ os: "linux" })),
|
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
@@ -322,10 +320,9 @@ function loadBackground(options) {
|
|||||||
require("../src/background/index");
|
require("../src/background/index");
|
||||||
|
|
||||||
// Send a message the way the browser would, and hand back whatever the
|
// Send a message the way the browser would, and hand back whatever the
|
||||||
// handler passed to sendResponse. `onResponse`, when given, receives the
|
// handler passed to sendResponse.
|
||||||
// answer when the handler sends it, as the sender's callback does.
|
function send(msg, sender) {
|
||||||
function send(msg, sender, onResponse) {
|
const sendResponse = jest.fn();
|
||||||
const sendResponse = jest.fn(onResponse);
|
|
||||||
const kept = messageListener(msg, sender || {}, sendResponse);
|
const kept = messageListener(msg, sender || {}, sendResponse);
|
||||||
return { sendResponse, kept };
|
return { sendResponse, kept };
|
||||||
}
|
}
|
||||||
@@ -387,26 +384,20 @@ function loadBackground(options) {
|
|||||||
|
|
||||||
// The same through eth_signTypedData_v4, whose params name the address
|
// The same through eth_signTypedData_v4, whose params name the address
|
||||||
// first and the typed data second.
|
// first and the typed data second.
|
||||||
function requestTypedData(typedData = TYPED_DATA) {
|
function requestTypedData() {
|
||||||
let rpcResult = null;
|
let rpcResult = null;
|
||||||
messageListener(
|
messageListener(
|
||||||
{
|
{
|
||||||
type: "AUTISTMASK_RPC",
|
type: "AUTISTMASK_RPC",
|
||||||
method: "eth_signTypedData_v4",
|
method: "eth_signTypedData_v4",
|
||||||
params: [signer.address, typedData],
|
params: [signer.address, TYPED_DATA],
|
||||||
},
|
},
|
||||||
{ origin: ORIGIN },
|
{ origin: ORIGIN },
|
||||||
(r) => {
|
(r) => {
|
||||||
rpcResult = r;
|
rpcResult = r;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return { result: () => rpcResult };
|
||||||
id: () =>
|
|
||||||
new URL(created[created.length - 1].url).searchParams.get(
|
|
||||||
"approval",
|
|
||||||
),
|
|
||||||
result: () => rpcResult,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A dApp asking to connect. The origin defaults to one the persisted
|
// A dApp asking to connect. The origin defaults to one the persisted
|
||||||
@@ -775,9 +766,10 @@ describe("one transaction approval at a time", () => {
|
|||||||
|
|
||||||
// The user closes an approval window that looks hung while the attempt
|
// The user closes an approval window that looks hung while the attempt
|
||||||
// behind it is still running, and that attempt then fails in a way that
|
// behind it is still running, and that attempt then fails in a way that
|
||||||
// leaves the approval standing for a retry. The toolbar popup opens it, so
|
// would normally leave the approval standing for a retry. There is no
|
||||||
// the user can still retry it, and answering it frees the slot.
|
// window left to retry in, so leaving it standing answers the requesting
|
||||||
test("an approval whose window closed under a failed attempt is retried from the toolbar popup, and frees the next request", async () => {
|
// page never — and holds the slot for the life of the worker with it.
|
||||||
|
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
|
||||||
const stalled = deferred();
|
const stalled = deferred();
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
@@ -808,28 +800,11 @@ describe("one transaction approval at a time", () => {
|
|||||||
|
|
||||||
stalled.resolve();
|
stalled.resolve();
|
||||||
await settle();
|
await settle();
|
||||||
expect(first.result()).toBeNull();
|
expect(first.result()).toEqual({
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
"src/popup/index.html?approval=" + first.id(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
bg.setStateReadHook(null);
|
bg.setStateReadHook(null);
|
||||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: first.id(),
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(NONCE),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(first.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
|
|
||||||
const second = bg.requestTx();
|
const second = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
expect(second.result()).toBeNull();
|
expect(second.result()).toBeNull();
|
||||||
@@ -870,10 +845,8 @@ describe("one transaction approval at a time", () => {
|
|||||||
const first = bg.requestTx();
|
const first = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
bg.send(
|
// The user closes the approval window, which rejects it.
|
||||||
{ type: "AUTISTMASK_TX_RESPONSE", id: first.id(), approved: false },
|
bg.closeWindow(1);
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
await settle();
|
||||||
expect(first.result()).toEqual({
|
expect(first.result()).toEqual({
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
@@ -899,57 +872,6 @@ describe("one transaction approval at a time", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chrome stops an idle worker, and the approvals in it go with it
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/540). tests/e2e/slowApproval.js
|
|
||||||
// shows the worker kept running in Chrome; this shows the calls stop once the
|
|
||||||
// approval is answered, so the worker can stop again. Every kind of approval is
|
|
||||||
// added through addPendingApproval(), which starts the calls, so a transaction
|
|
||||||
// approval stands for all of them.
|
|
||||||
describe("the worker keeps running while an approval is pending", () => {
|
|
||||||
test("an extension call every 20 seconds until the approval is answered", async () => {
|
|
||||||
const startTimer = jest
|
|
||||||
.spyOn(global, "setInterval")
|
|
||||||
.mockImplementation(() => 1);
|
|
||||||
const stopTimer = jest
|
|
||||||
.spyOn(global, "clearInterval")
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
try {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const calls = global.chrome.runtime.getPlatformInfo;
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
expect(startTimer).toHaveBeenCalledTimes(1);
|
|
||||||
expect(startTimer.mock.calls[0][1]).toBe(20000);
|
|
||||||
const tick = startTimer.mock.calls[0][0];
|
|
||||||
|
|
||||||
tick();
|
|
||||||
expect(calls).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// A closed window leaves the approval pending, so the calls go on.
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
tick();
|
|
||||||
expect(calls).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: false,
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
tick();
|
|
||||||
expect(calls).toHaveBeenCalledTimes(2);
|
|
||||||
expect(stopTimer).toHaveBeenCalledWith(1);
|
|
||||||
} finally {
|
|
||||||
startTimer.mockRestore();
|
|
||||||
stopTimer.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A page that asks again before the user has answered its last connection or
|
// A page that asks again before the user has answered its last connection or
|
||||||
// signature request is refused, instead of opening one more window per call.
|
// signature request is refused, instead of opening one more window per call.
|
||||||
describe("one connection and one signature approval per site at a time", () => {
|
describe("one connection and one signature approval per site at a time", () => {
|
||||||
@@ -1193,12 +1115,9 @@ describe("a nonce collision is reported as a transaction that did not go out", (
|
|||||||
const pending = bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// As ethers throws it: its own classification, and the node's
|
|
||||||
// JSON-RPC error under info.error.
|
|
||||||
bg.broadcastTransaction.mockRejectedValue(
|
bg.broadcastTransaction.mockRejectedValue(
|
||||||
Object.assign(new Error("nonce too low"), {
|
Object.assign(new Error("nonce too low"), {
|
||||||
code: "NONCE_EXPIRED",
|
code: "NONCE_EXPIRED",
|
||||||
info: { error: { code: -32000, message: "nonce too low" } },
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const answer = bg.send(
|
const answer = bg.send(
|
||||||
@@ -1217,10 +1136,8 @@ describe("a nonce collision is reported as a transaction that did not go out", (
|
|||||||
retryable: false,
|
retryable: false,
|
||||||
stage: "nonce",
|
stage: "nonce",
|
||||||
});
|
});
|
||||||
// The wallet's sentence, with the node's own code.
|
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toEqual({
|
||||||
error: {
|
error: {
|
||||||
code: -32000,
|
|
||||||
message: expect.stringMatching(
|
message: expect.stringMatching(
|
||||||
/transaction was not sent, because its nonce/,
|
/transaction was not sent, because its nonce/,
|
||||||
),
|
),
|
||||||
@@ -1270,7 +1187,6 @@ describe("a nonce collision is reported as a transaction that did not go out", (
|
|||||||
});
|
});
|
||||||
expect(second.result()).toEqual({
|
expect(second.result()).toEqual({
|
||||||
error: {
|
error: {
|
||||||
code: -32603,
|
|
||||||
message: expect.stringMatching(/nonce had already been used/),
|
message: expect.stringMatching(/nonce had already been used/),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1361,7 +1277,6 @@ describe("what the approval is verified against", () => {
|
|||||||
);
|
);
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toEqual({
|
||||||
error: {
|
error: {
|
||||||
code: -32603,
|
|
||||||
message: expect.stringMatching(/approved maximum fee per gas/),
|
message: expect.stringMatching(/approved maximum fee per gas/),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1421,10 +1336,7 @@ describe("what the approval is verified against", () => {
|
|||||||
expect.objectContaining({ retryable: false, stage: "verify" }),
|
expect.objectContaining({ retryable: false, stage: "verify" }),
|
||||||
);
|
);
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toEqual({
|
||||||
error: {
|
error: { message: expect.stringMatching(/active address changed/) },
|
||||||
code: -32603,
|
|
||||||
message: expect.stringMatching(/active address changed/),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1568,102 +1480,10 @@ describe("what the approval is verified against", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toEqual({
|
||||||
error: {
|
error: { message: expect.stringMatching(/active address changed/) },
|
||||||
code: -32603,
|
|
||||||
message: expect.stringMatching(/active address changed/),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Typed data whose domain names another chain is refused on the sign
|
|
||||||
// screen; a signature for it that reaches the background anyway is not
|
|
||||||
// handed to the page (https://git.eeqj.de/sneak/AutistMask/issues/519).
|
|
||||||
// TYPED_DATA names mainnet.
|
|
||||||
async function answerTypedData(bg, pending, typedData = TYPED_DATA) {
|
|
||||||
const { domain, types, message } = JSON.parse(typedData);
|
|
||||||
delete types.EIP712Domain;
|
|
||||||
const signature = await signer.signTypedData(domain, types, message);
|
|
||||||
const answer = bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: true,
|
|
||||||
signature,
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
return { answer, signature };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("typed data for the active chain is handed back signed", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTypedData();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const { answer, signature } = await answerTypedData(bg, pending);
|
|
||||||
|
|
||||||
expect(answer.sendResponse).toHaveBeenCalledWith({ signature });
|
|
||||||
expect(pending.result()).toEqual({ result: signature });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("typed data for another chain is refused, not handed back", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
bg.setNetwork(SEPOLIA);
|
|
||||||
const pending = bg.requestTypedData();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const { answer } = await answerTypedData(bg, pending);
|
|
||||||
|
|
||||||
const refusal =
|
|
||||||
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
|
|
||||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: refusal,
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
expect(pending.result()).toEqual({
|
|
||||||
error: { code: -32603, message: refusal },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ethers signs a `bool` field sent as the string "false" as true, and one
|
|
||||||
// left out as false. The sign screen refuses both; a signature for either
|
|
||||||
// that reaches the background anyway is not handed to the page
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/552).
|
|
||||||
test.each([
|
|
||||||
[
|
|
||||||
'"false"',
|
|
||||||
"false",
|
|
||||||
'The field "approved" is not true or false, so this typed data cannot be signed.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"left out",
|
|
||||||
undefined,
|
|
||||||
'The field "approved" is missing from the message, so this typed data cannot be signed.',
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
"typed data with a bool %s is refused, not handed back",
|
|
||||||
async (_, approved, refusal) => {
|
|
||||||
const data = JSON.parse(TYPED_DATA);
|
|
||||||
data.types.Note.push({ name: "approved", type: "bool" });
|
|
||||||
data.message.approved = approved;
|
|
||||||
const typedData = JSON.stringify(data);
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTypedData(typedData);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const { answer } = await answerTypedData(bg, pending, typedData);
|
|
||||||
|
|
||||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: refusal,
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
expect(pending.result()).toEqual({
|
|
||||||
error: { code: -32603, message: refusal },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("a sign request from another address raises no approval at all", async () => {
|
test("a sign request from another address raises no approval at all", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const pending = bg.requestSign(other.address);
|
const pending = bg.requestSign(other.address);
|
||||||
@@ -1698,7 +1518,6 @@ describe("what the approval is verified against", () => {
|
|||||||
|
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toEqual({
|
||||||
error: {
|
error: {
|
||||||
code: -32603,
|
|
||||||
message: expect.stringMatching(
|
message: expect.stringMatching(
|
||||||
/active address changed while this transaction was being prepared/,
|
/active address changed while this transaction was being prepared/,
|
||||||
),
|
),
|
||||||
@@ -1721,11 +1540,8 @@ describe("what the approval is verified against", () => {
|
|||||||
const pending = bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// A failure with no node error in it is answered as the wallet's own;
|
|
||||||
// tests/rpcErrorCodes.test.js has the node's own revert.
|
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toEqual({
|
||||||
error: {
|
error: {
|
||||||
code: -32603,
|
|
||||||
message: expect.stringMatching(
|
message: expect.stringMatching(
|
||||||
/could not be prepared.*execution reverted/,
|
/could not be prepared.*execution reverted/,
|
||||||
),
|
),
|
||||||
@@ -1735,55 +1551,6 @@ describe("what the approval is verified against", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// eth_sign names the address first and the data second, the reverse of
|
|
||||||
// personal_sign, and is answered as the JSON-RPC specification defines it:
|
|
||||||
// with an EIP-191 personal message signature over the data, the same
|
|
||||||
// signature personal_sign returns
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/409). The signature below is
|
|
||||||
// made by the test; that the sign screen makes the same one is tested in
|
|
||||||
// tests/ethSign.test.js.
|
|
||||||
describe("eth_sign", () => {
|
|
||||||
test("is read address first, and returns a personal message signature over the data", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const page = bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_RPC",
|
|
||||||
method: "eth_sign",
|
|
||||||
params: [signer.address, MESSAGE],
|
|
||||||
},
|
|
||||||
{ origin: ORIGIN },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
const id = approvalIdIn(bg.created.map((c) => c.url));
|
|
||||||
|
|
||||||
const details = bg.send(
|
|
||||||
{ type: "AUTISTMASK_GET_APPROVAL", id },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
const { signParams } = details.sendResponse.mock.calls[0][0];
|
|
||||||
expect(signParams).toMatchObject({
|
|
||||||
method: "eth_sign",
|
|
||||||
message: MESSAGE,
|
|
||||||
from: signer.address,
|
|
||||||
});
|
|
||||||
expect(signParams.dangerWarning).toBe(
|
|
||||||
"This site used the older eth_sign method. " +
|
|
||||||
"The data will be signed as a personal message.",
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await signer.signMessage(
|
|
||||||
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
||||||
);
|
|
||||||
bg.send(
|
|
||||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: true, signature },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(page.sendResponse).toHaveBeenCalledWith({ result: signature });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The interlock must not cost the retry the approval exists to allow.
|
// The interlock must not cost the retry the approval exists to allow.
|
||||||
describe("the interlock releases a failed attempt", () => {
|
describe("the interlock releases a failed attempt", () => {
|
||||||
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
||||||
@@ -1872,237 +1639,6 @@ describe("the interlock releases a failed attempt", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A transaction or signature approval stays pending when its window closes.
|
|
||||||
// The browser can close that window when the user clicks away from it, for
|
|
||||||
// example to copy the password from a password manager. The toolbar popup then
|
|
||||||
// opens the approval, and the user answers it there
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
|
|
||||||
describe("a closed approval window leaves the request pending", () => {
|
|
||||||
// The URL the toolbar popup opens now.
|
|
||||||
function toolbarPopup(bg) {
|
|
||||||
return bg.actionPopups[bg.actionPopups.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The timer that keeps the worker running while an approval is pending is
|
|
||||||
// stubbed, so no test here leaves that timer running when it ends.
|
|
||||||
let keepRunningTimer;
|
|
||||||
beforeEach(() => {
|
|
||||||
keepRunningTimer = jest
|
|
||||||
.spyOn(global, "setInterval")
|
|
||||||
.mockImplementation(() => 1);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
keepRunningTimer.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
expect(toolbarPopup(bg)).toBe(
|
|
||||||
"src/popup/index.html?approval=" + pending.id(),
|
|
||||||
);
|
|
||||||
|
|
||||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(NONCE),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Reject in the toolbar popup answers 4001, and the toolbar popup opens the wallet again", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
expect(toolbarPopup(bg)).toBe(
|
|
||||||
"src/popup/index.html?approval=" + pending.id(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reject = bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: false,
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
// The popup closes its window only once this answer arrives.
|
|
||||||
expect(reject.sendResponse).toHaveBeenCalledWith(null);
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("with two closed, the toolbar popup opens the older one first, then the other", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const sign = bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
const signId = sign.id();
|
|
||||||
const tx = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// The signature request is in window 1 and the transaction in window
|
|
||||||
// 2. The newer window is closed first.
|
|
||||||
bg.closeWindow(2);
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(toolbarPopup(bg)).toBe(
|
|
||||||
"src/popup/index.html?approval=" + signId,
|
|
||||||
);
|
|
||||||
|
|
||||||
bg.send(
|
|
||||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id: signId, approved: false },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(toolbarPopup(bg)).toBe(
|
|
||||||
"src/popup/index.html?approval=" + tx.id(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reject = bg.send(
|
|
||||||
{ type: "AUTISTMASK_TX_RESPONSE", id: tx.id(), approved: false },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(tx.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
expect(reject.sendResponse).toHaveBeenCalledWith(null);
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user can close the window while the attempt after Confirm runs. That
|
|
||||||
// attempt answers the request, so meanwhile the toolbar popup opens the
|
|
||||||
// wallet. Opened from the toolbar, the approval would look unanswered.
|
|
||||||
test("when the window closes during an attempt that sends, the toolbar popup keeps opening the wallet", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const inFlight = deferred();
|
|
||||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(NONCE),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
|
|
||||||
inFlight.resolve({ hash: "0xfeed" });
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when the window closes during an attempt that fails retryably, the toolbar popup opens the wallet until the attempt ends, then the approval", async () => {
|
|
||||||
const stalled = deferred();
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
bg.setStateReadHook(async () => {
|
|
||||||
await stalled.promise;
|
|
||||||
throw new Error("The wallet data could not be read.");
|
|
||||||
});
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(NONCE),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
|
|
||||||
stalled.resolve();
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
expect(toolbarPopup(bg)).toBe(
|
|
||||||
"src/popup/index.html?approval=" + pending.id(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A retry from the toolbar popup holds the approval again, so the
|
|
||||||
// toolbar popup opens the wallet while that retry runs.
|
|
||||||
bg.setStateReadHook(null);
|
|
||||||
const inFlight = deferred();
|
|
||||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: pending.id(),
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(NONCE),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
|
|
||||||
inFlight.resolve({ hash: "0xfeed" });
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an address switch still rejects a closed approval", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
|
|
||||||
bg.send(
|
|
||||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The worker can stop while an approval waits with its window closed. The
|
|
||||||
// approval is lost with the worker, but the toolbar popup keeps its URL.
|
|
||||||
// The next worker points the toolbar popup back at the wallet.
|
|
||||||
test("a worker that starts points the toolbar popup back at the wallet", () => {
|
|
||||||
const bg = loadBackground({
|
|
||||||
toolbarPopup: "src/popup/index.html?approval=lost-approval",
|
|
||||||
});
|
|
||||||
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The claim is what makes one approval one broadcast, so it has to hold
|
// The claim is what makes one approval one broadcast, so it has to hold
|
||||||
// against everything else that retires an approval, not just against a second
|
// against everything else that retires an approval, not just against a second
|
||||||
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
||||||
@@ -2223,6 +1759,20 @@ describe("a claimed approval outlives every other retirement path", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The refusals above must not cost the rejection its ordinary meaning.
|
// The refusals above must not cost the rejection its ordinary meaning.
|
||||||
|
test("with no attempt running, closing the window still rejects", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const pending = bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
@@ -2832,12 +2382,15 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// That skip must not reach a tx or sign approval. The popup connects its
|
// The `isSite &&` half of that skip, which is what keeps it from reaching
|
||||||
// port in show() before it knows the approval's type, and the background
|
// a tx or sign approval. The popup connects its port in show() before it
|
||||||
// sets portConnected without looking at the type either, so a tx approval
|
// knows the approval's type, and the background sets portConnected without
|
||||||
// carries the flag too. If the window event skipped it, windowClosed would
|
// looking at the type either, so a tx approval in the fallback window
|
||||||
// never be set, and the toolbar popup would never open the approval.
|
// carries the flag too. Without the conjunct the window event would skip
|
||||||
test("a tx window closed with the port connected is still opened by the toolbar popup", async () => {
|
// it, windowClosed would never be set, releaseApproval() would never settle
|
||||||
|
// it, and the page would hang — the #271 regression this guard is written
|
||||||
|
// around.
|
||||||
|
test("a tx window closed with the port connected still rejects", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const pending = bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -2845,10 +2398,9 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
bg.closeWindow(1);
|
bg.closeWindow(1);
|
||||||
await settle();
|
await settle();
|
||||||
expect(pending.result()).toBeNull();
|
expect(pending.result()).toEqual({
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
"src/popup/index.html?approval=" + pending.id(),
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2862,9 +2414,9 @@ async function siteAccounts(bg, origin) {
|
|||||||
return sendResponse.mock.calls[0][0];
|
return sendResponse.mock.calls[0][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// A site connected without "Remember" is held only in the background's session
|
// A site connected without "Remember" is held only in the background's memory,
|
||||||
// storage, keyed to the address it was connected to. Removing that address, or
|
// keyed to the address it was connected to. Removing that address, or the
|
||||||
// the wallet holding it, must end the connection as part of the removal itself.
|
// wallet holding it, must end the connection as part of the removal itself.
|
||||||
// The accountsChanged broadcast the views send afterwards also clears it, but
|
// The accountsChanged broadcast the views send afterwards also clears it, but
|
||||||
// only when the active address moved, and nothing waits for it to arrive.
|
// only when the active address moved, and nothing waits for it to arrive.
|
||||||
//
|
//
|
||||||
@@ -3043,7 +2595,8 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
const element = (id) => (lists[id] ||= fakeSiteList());
|
const element = (id) => (lists[id] ||= fakeSiteList());
|
||||||
global.document = { getElementById: element };
|
global.document = { getElementById: element };
|
||||||
global.chrome.runtime.sendMessage = (msg, callback) => {
|
global.chrome.runtime.sendMessage = (msg, callback) => {
|
||||||
bg.send(msg, bg.fromPopup, callback);
|
const { sendResponse } = bg.send(msg, bg.fromPopup);
|
||||||
|
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
|
||||||
};
|
};
|
||||||
await require("../src/shared/state").loadState();
|
await require("../src/shared/state").loadState();
|
||||||
await require("../src/popup/views/settings").renderSiteLists();
|
await require("../src/popup/views/settings").renderSiteLists();
|
||||||
@@ -3197,7 +2750,7 @@ describe("where an approval window opens", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
expect(bg.created).toHaveLength(1);
|
||||||
expect(bg.created[0]).toMatchObject({ left: 442, top: 60 });
|
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("not centred on an approval window the user was last in", async () => {
|
test("not centred on an approval window the user was last in", async () => {
|
||||||
@@ -3214,7 +2767,7 @@ describe("where an approval window opens", () => {
|
|||||||
bg.requestSign();
|
bg.requestSign();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// Centred, it would be at left 882, the position the browser refused.
|
// Centred, it would be at left 900, the position the browser refused.
|
||||||
expect(bg.created).toHaveLength(1);
|
expect(bg.created).toHaveLength(1);
|
||||||
expect(bg.created[0].left).toBeUndefined();
|
expect(bg.created[0].left).toBeUndefined();
|
||||||
expect(bg.created[0].top).toBeUndefined();
|
expect(bg.created[0].top).toBeUndefined();
|
||||||
@@ -3236,18 +2789,17 @@ describe("where an approval window opens", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(2);
|
expect(bg.created).toHaveLength(2);
|
||||||
expect(bg.created[0]).toMatchObject({ left: 1502, top: 750 });
|
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
|
||||||
expect(bg.created[1].left).toBeUndefined();
|
expect(bg.created[1].left).toBeUndefined();
|
||||||
expect(bg.created[1].top).toBeUndefined();
|
expect(bg.created[1].top).toBeUndefined();
|
||||||
|
|
||||||
// The request waits on the second window rather than failing. Once
|
// The request waits on the second window rather than failing:
|
||||||
// that window closes, the toolbar popup opens the request.
|
// closing that window is refusing the prompt.
|
||||||
expect(sign.result()).toBeNull();
|
expect(sign.result()).toBeNull();
|
||||||
bg.closeWindow(2);
|
bg.closeWindow(2);
|
||||||
await settle();
|
await settle();
|
||||||
expect(sign.result()).toBeNull();
|
expect(sign.result()).toEqual({
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
"src/popup/index.html?approval=" + sign.id(),
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,16 +19,12 @@
|
|||||||
// that followed persisted the pre-refresh values — while still stamping
|
// that followed persisted the pre-refresh values — while still stamping
|
||||||
// lastBalanceRefresh, suppressing the redo.
|
// lastBalanceRefresh, suppressing the redo.
|
||||||
//
|
//
|
||||||
// A network switch landing inside a balance refresh's round trip is covered at
|
// Both use the real persistence path over a cloning storage stub. Nothing here
|
||||||
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
|
|
||||||
//
|
|
||||||
// All use the real persistence path over a cloning storage stub. Nothing here
|
|
||||||
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
|
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
|
||||||
// holds against any implementation that gets the outcome right.
|
// holds against any implementation that gets the outcome right.
|
||||||
|
|
||||||
const { Wallet } = require("ethers");
|
const { Wallet } = require("ethers");
|
||||||
const { networkById } = require("../src/shared/networks");
|
const { networkById } = require("../src/shared/networks");
|
||||||
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
|
||||||
const SIGNER_KEY =
|
const SIGNER_KEY =
|
||||||
@@ -142,8 +138,6 @@ function loadWorker(networkId, opts) {
|
|||||||
},
|
},
|
||||||
refreshBalances:
|
refreshBalances:
|
||||||
options.refreshBalances || jest.fn(async () => undefined),
|
options.refreshBalances || jest.fn(async () => undefined),
|
||||||
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
|
|
||||||
.dropUntrackedTokens,
|
|
||||||
}));
|
}));
|
||||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||||
isPhishingDomain: () => false,
|
isPhishingDomain: () => false,
|
||||||
@@ -246,8 +240,8 @@ describe("a chain switch under a transaction already committed to a chain", () =
|
|||||||
// The artifact is verified against the chain read at the top of the
|
// The artifact is verified against the chain read at the top of the
|
||||||
// attempt. Whatever endpoint it is then broadcast to has to be that same
|
// attempt. Whatever endpoint it is then broadcast to has to be that same
|
||||||
// chain's — otherwise the wallet checks a transaction against Sepolia and
|
// chain's — otherwise the wallet checks a transaction against Sepolia and
|
||||||
// sends it to a mainnet node. The user can switch the network in Settings
|
// sends it to a mainnet node. A connected site can switch the chain at any
|
||||||
// at any moment, including this one.
|
// moment, including this one.
|
||||||
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
|
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
|
||||||
const bg = loadWorker("sepolia");
|
const bg = loadWorker("sepolia");
|
||||||
|
|
||||||
@@ -270,9 +264,9 @@ describe("a chain switch under a transaction already committed to a chain", () =
|
|||||||
populated(Number(SEPOLIA.networkVersion)),
|
populated(Number(SEPOLIA.networkVersion)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The user switches the network in Settings while the attempt is
|
// A connected site switches the chain while the attempt is running,
|
||||||
// running: the popup writes the switched record to storage in full
|
// and the switch is committed to storage in full before the attempt
|
||||||
// before the attempt goes any further.
|
// goes any further.
|
||||||
//
|
//
|
||||||
// It is fired from inside the attempt's SECOND state read, because
|
// It is fired from inside the attempt's SECOND state read, because
|
||||||
// that is where the window used to be: the chain id was captured at
|
// that is where the window used to be: the chain id was captured at
|
||||||
@@ -283,18 +277,18 @@ describe("a chain switch under a transaction already committed to a chain", () =
|
|||||||
// this to fire on, and the switch below runs after the attempt is
|
// this to fire on, and the switch below runs after the attempt is
|
||||||
// done instead — which is the point.
|
// done instead — which is the point.
|
||||||
let reads = 0;
|
let reads = 0;
|
||||||
let switched = false;
|
let switched = null;
|
||||||
const doSwitch = () => {
|
const doSwitch = async () => {
|
||||||
const record = bg.persisted();
|
switched = bg.rpc("wallet_switchEthereumChain", [
|
||||||
applyChainSwitchFields(record, MAINNET.id);
|
{ chainId: MAINNET.chainId },
|
||||||
global.chrome.storage.write("autistmask", record);
|
]);
|
||||||
switched = true;
|
await settle();
|
||||||
};
|
};
|
||||||
bg.setGetHook(async () => {
|
bg.setGetHook(async () => {
|
||||||
reads++;
|
reads++;
|
||||||
if (reads !== 2) return;
|
if (reads !== 2) return;
|
||||||
bg.setGetHook(null);
|
bg.setGetHook(null);
|
||||||
doSwitch();
|
await doSwitch();
|
||||||
});
|
});
|
||||||
|
|
||||||
const attempt = bg.send(
|
const attempt = bg.send(
|
||||||
@@ -309,7 +303,8 @@ describe("a chain switch under a transaction already committed to a chain", () =
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
bg.setGetHook(null);
|
bg.setGetHook(null);
|
||||||
if (!switched) doSwitch();
|
if (!switched) await doSwitch();
|
||||||
|
expect(switched.result()).toEqual({ result: null });
|
||||||
expect(bg.persisted().networkId).toBe("mainnet");
|
expect(bg.persisted().networkId).toBe("mainnet");
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
@@ -400,99 +395,3 @@ describe("a balance refresh under another handler's state read", () => {
|
|||||||
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
|
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("a balance refresh under a network switch", () => {
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
|
|
||||||
// address's balances, and a refresh that started on the previous network
|
|
||||||
// must not write that network's back over them when it returns. The same
|
|
||||||
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
|
|
||||||
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
|
|
||||||
const roundTrip = deferred();
|
|
||||||
const reachedNetwork = deferred();
|
|
||||||
const ranOn = [];
|
|
||||||
|
|
||||||
const bg = loadWorker("sepolia", {
|
|
||||||
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
|
|
||||||
ranOn.push(id);
|
|
||||||
reachedNetwork.resolve();
|
|
||||||
await roundTrip.promise;
|
|
||||||
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const refresh = bg.fireBalanceAlarm();
|
|
||||||
await reachedNetwork.promise;
|
|
||||||
|
|
||||||
// The user switches to Ethereum Mainnet in Settings while the round
|
|
||||||
// trip is out.
|
|
||||||
const during = bg.persisted();
|
|
||||||
applyChainSwitchFields(during, MAINNET.id);
|
|
||||||
global.chrome.storage.write("autistmask", during);
|
|
||||||
|
|
||||||
roundTrip.resolve();
|
|
||||||
await refresh;
|
|
||||||
|
|
||||||
const after = bg.persisted();
|
|
||||||
expect(after.networkId).toBe("mainnet");
|
|
||||||
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
|
||||||
expect(after.lastBalanceRefresh).toBe(0);
|
|
||||||
|
|
||||||
await bg.fireBalanceAlarm();
|
|
||||||
|
|
||||||
expect(ranOn).toEqual(["sepolia", "mainnet"]);
|
|
||||||
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
|
|
||||||
REFRESHED_BALANCE,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a balance refresh under a change of Use Blockscout", () => {
|
|
||||||
// Turning "Use Blockscout" off drops the tokens the user has not tracked
|
|
||||||
// (src/popup/views/settings.js). Blockscout may have answered a refresh
|
|
||||||
// just before. That refresh still saves its balances when it returns, but
|
|
||||||
// not those tokens. The same refresh in the popup is in
|
|
||||||
// tests/blockscoutEtherscanSettings.test.js.
|
|
||||||
test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => {
|
|
||||||
const roundTrip = deferred();
|
|
||||||
const started = deferred();
|
|
||||||
|
|
||||||
const bg = loadWorker("mainnet", {
|
|
||||||
refreshBalances: async (wallets) => {
|
|
||||||
started.resolve();
|
|
||||||
await roundTrip.promise;
|
|
||||||
wallets[0].addresses[0].balance = "7.0";
|
|
||||||
wallets[0].addresses[0].tokenBalances = [
|
|
||||||
{
|
|
||||||
address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
||||||
name: "Test Token",
|
|
||||||
symbol: "TST",
|
|
||||||
decimals: 18,
|
|
||||||
balance: "5.0",
|
|
||||||
holders: 50000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
global.chrome.storage.write("autistmask", {
|
|
||||||
...bg.persisted(),
|
|
||||||
useBlockscout: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const refresh = bg.fireBalanceAlarm();
|
|
||||||
await started.promise;
|
|
||||||
|
|
||||||
// The user unticks "Use Blockscout" in Settings while the round trip
|
|
||||||
// is out.
|
|
||||||
global.chrome.storage.write("autistmask", {
|
|
||||||
...bg.persisted(),
|
|
||||||
useBlockscout: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
roundTrip.resolve();
|
|
||||||
await refresh;
|
|
||||||
|
|
||||||
const after = bg.persisted();
|
|
||||||
expect(after.wallets[0].addresses[0].balance).toBe("7.0");
|
|
||||||
expect(after.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -18,17 +18,6 @@ const {
|
|||||||
const RPC_URL = "https://rpc.example.invalid";
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
||||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
const TRACKED = {
|
|
||||||
address: "0x7777777777777777777777777777777777777777",
|
|
||||||
symbol: "TRK",
|
|
||||||
decimals: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Blockscout is asked only while the stored "Use Blockscout" is on.
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
let logged;
|
let logged;
|
||||||
@@ -41,9 +30,9 @@ beforeEach(() => {
|
|||||||
const failedToFetch = async () => {
|
const failedToFetch = async () => {
|
||||||
throw new TypeError("Failed to fetch");
|
throw new TypeError("Failed to fetch");
|
||||||
};
|
};
|
||||||
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and
|
// The RPC calls (ETH balance, ENS name, and the scan's balance and
|
||||||
// the scan's balance and transaction count) and the explorer request (the
|
// transaction count) and the explorer request (token balances) all fail
|
||||||
// other token balances) all fail the same way.
|
// the same way.
|
||||||
FetchRequest.registerGetUrl(failedToFetch);
|
FetchRequest.registerGetUrl(failedToFetch);
|
||||||
globalThis.fetch = jest.fn(failedToFetch);
|
globalThis.fetch = jest.fn(failedToFetch);
|
||||||
});
|
});
|
||||||
@@ -60,7 +49,7 @@ function refresh(signal) {
|
|||||||
wallets,
|
wallets,
|
||||||
RPC_URL,
|
RPC_URL,
|
||||||
EXPLORER_URL,
|
EXPLORER_URL,
|
||||||
[TRACKED],
|
[],
|
||||||
"mainnet",
|
"mainnet",
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
@@ -71,7 +60,6 @@ test("a failure while the popup is open is reported", async () => {
|
|||||||
for (const label of [
|
for (const label of [
|
||||||
"ETH balance failed",
|
"ETH balance failed",
|
||||||
"ENS reverse failed",
|
"ENS reverse failed",
|
||||||
"token balance failed",
|
|
||||||
"fetchTokenBalances failed: Failed to fetch",
|
"fetchTokenBalances failed: Failed to fetch",
|
||||||
]) {
|
]) {
|
||||||
expect(logged.some((line) => line.includes(label))).toBe(true);
|
expect(logged.some((line) => line.includes(label))).toBe(true);
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
// A balance refresh the popup started on one network, and that returns after
|
|
||||||
// the network was switched to another, writes nothing: the switch cleared every
|
|
||||||
// address's balances, and the previous network's must not come back over them
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
|
|
||||||
// by the user in this popup's Settings, or by the background after the user
|
|
||||||
// approved a site's request, which changes only the stored record. Driven
|
|
||||||
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
|
||||||
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
|
||||||
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
TOKEN_ADDRESS,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Starts the ten-second refresh and holds it open. The balance stand-in writes
|
|
||||||
// Ethereum Mainnet's balances in place, on the records handed in, as the real
|
|
||||||
// refreshBalances() does, once the returned function is called. Resolves with
|
|
||||||
// that function and the refresh still running.
|
|
||||||
async function holdRefresh(page) {
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
|
||||||
let answer;
|
|
||||||
refreshBalances.mockImplementationOnce(async (wallets) => {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
answer = resolve;
|
|
||||||
});
|
|
||||||
wallets[0].addresses[0].balance = "7.0";
|
|
||||||
wallets[0].addresses[0].tokenBalances = [
|
|
||||||
{
|
|
||||||
address: TOKEN_ADDRESS,
|
|
||||||
name: "Test Token",
|
|
||||||
symbol: "TST",
|
|
||||||
decimals: 18,
|
|
||||||
balance: "5.0",
|
|
||||||
holders: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const ticking = page.tick();
|
|
||||||
await page.settle();
|
|
||||||
return {
|
|
||||||
finish: async () => {
|
|
||||||
answer();
|
|
||||||
await ticking;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picks `networkId` in the Settings network selector, as the user does.
|
|
||||||
async function chooseNetwork(page, networkId) {
|
|
||||||
const select = page.node("settings-network");
|
|
||||||
select.value = networkId;
|
|
||||||
for (const fn of select.listeners.change) await fn();
|
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
|
|
||||||
// recorded as done.
|
|
||||||
function expectNothingStored(page) {
|
|
||||||
const stored = page.storage.read("autistmask");
|
|
||||||
expect(stored.networkId).toBe("sepolia");
|
|
||||||
expect(stored.wallets[0].addresses[0].balance).toBe("0");
|
|
||||||
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
|
||||||
expect(stored.lastBalanceRefresh).toBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
|
|
||||||
const refresh = await holdRefresh(page);
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await chooseNetwork(page, "sepolia");
|
|
||||||
await refresh.finish();
|
|
||||||
|
|
||||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
|
||||||
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
|
||||||
expectNothingStored(page);
|
|
||||||
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
|
||||||
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
|
|
||||||
|
|
||||||
await page.tick();
|
|
||||||
|
|
||||||
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
|
|
||||||
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The switch is made only once the refresh's save, holding the lock, has
|
|
||||||
// written Ethereum Mainnet's balances, and the switch then clears them.
|
|
||||||
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
|
|
||||||
const refresh = await holdRefresh(page);
|
|
||||||
await page.click("btn-settings");
|
|
||||||
|
|
||||||
// The next read of the stored record, the refresh's save's, is held.
|
|
||||||
const get = page.storage.local.get;
|
|
||||||
const read = get.getMockImplementation();
|
|
||||||
let releaseRead;
|
|
||||||
get.mockImplementationOnce(async (key) => {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
releaseRead = resolve;
|
|
||||||
});
|
|
||||||
return read(key);
|
|
||||||
});
|
|
||||||
const finishing = refresh.finish();
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
// The user picks Sepolia meanwhile; the switch's own save waits for the
|
|
||||||
// refresh's save to let the lock go.
|
|
||||||
const select = page.node("settings-network");
|
|
||||||
select.value = "sepolia";
|
|
||||||
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
releaseRead();
|
|
||||||
await finishing;
|
|
||||||
await switching;
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(state.networkId).toBe("sepolia");
|
|
||||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
|
||||||
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
|
||||||
expectNothingStored(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh that returns after the background switched the network writes nothing", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
const { saveState } = require("../src/shared/state");
|
|
||||||
|
|
||||||
const refresh = await holdRefresh(page);
|
|
||||||
// The background writes the switch to Sepolia, as its updateState() does
|
|
||||||
// once the user approves a site's request. This page is not told of it
|
|
||||||
// yet.
|
|
||||||
const record = page.storage.read("autistmask");
|
|
||||||
applyChainSwitchFields(record, "sepolia");
|
|
||||||
page.storage.write("autistmask", record);
|
|
||||||
await refresh.finish();
|
|
||||||
|
|
||||||
expectNothingStored(page);
|
|
||||||
|
|
||||||
// Nor does a later save by this page, such as every navigation makes.
|
|
||||||
await saveState();
|
|
||||||
|
|
||||||
expectNothingStored(page);
|
|
||||||
});
|
|
||||||
@@ -1,850 +0,0 @@
|
|||||||
// Blockscout and Etherscan are sent none of the user's data until the user
|
|
||||||
// turns on a setting for each
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/668). A new profile has "Use
|
|
||||||
// Blockscout" and "Check recipients on Etherscan" both off. While a setting is
|
|
||||||
// off, no request sends its service the user's data.
|
|
||||||
//
|
|
||||||
// One request still reaches Blockscout while "Use Blockscout" is off: the check
|
|
||||||
// behind "Save" beside the Blockscout URL in Settings. That check asks the URL
|
|
||||||
// only for `/stats` and sends none of the user's data.
|
|
||||||
//
|
|
||||||
// Before any other request to Blockscout or Etherscan, the wallet reads that
|
|
||||||
// service's setting from storage, not the page's copy. The wallet reads the
|
|
||||||
// setting again when the answer lands. So a change saved by another page
|
|
||||||
// applies at once. An open page also takes such a change and draws its screens
|
|
||||||
// again.
|
|
||||||
//
|
|
||||||
// Only Blockscout reports the tokens the user has not tracked. While "Use
|
|
||||||
// Blockscout" is off, no address keeps them and no screen lists them.
|
|
||||||
//
|
|
||||||
// The functions that make the requests are driven directly, against a fetch()
|
|
||||||
// that records every URL it is asked for. The screens are driven through the
|
|
||||||
// real popup over a stored profile (tests/support/popupBoot.js). The fetch()
|
|
||||||
// stub in popupBoot.js records the same way.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { FetchRequest } = require("ethers");
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
ADDRESS,
|
|
||||||
TOKEN_ADDRESS,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
const {
|
|
||||||
DEFAULT_STATE,
|
|
||||||
normalizePersisted,
|
|
||||||
} = require("../src/shared/persistedState");
|
|
||||||
const {
|
|
||||||
fetchTokenBalances,
|
|
||||||
refreshBalances,
|
|
||||||
} = require("../src/shared/balances");
|
|
||||||
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
|
||||||
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
|
||||||
const { checkEtherscanLabel } = require("../src/shared/etherscanLabels");
|
|
||||||
const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers");
|
|
||||||
|
|
||||||
const RPC_URL = "https://rpc.example.invalid";
|
|
||||||
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
|
||||||
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
|
||||||
const TX_HASH = "0x" + "ab".repeat(32);
|
|
||||||
const ZERO_WORD = "0x" + "0".repeat(64);
|
|
||||||
|
|
||||||
// USDC, from the bundled list, as Blockscout reports a holding of it.
|
|
||||||
const USDC_ROW = {
|
|
||||||
value: "5000000",
|
|
||||||
token: {
|
|
||||||
type: "ERC-20",
|
|
||||||
address_hash: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
|
||||||
symbol: "USDC",
|
|
||||||
name: "USD Coin",
|
|
||||||
decimals: "6",
|
|
||||||
holders_count: "50000",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// A transfer of 1 ETH from RECIPIENT to ADDRESS, as Blockscout lists it in
|
|
||||||
// ADDRESS's history.
|
|
||||||
const HISTORY_ITEM = {
|
|
||||||
hash: TX_HASH,
|
|
||||||
block_number: 21000000,
|
|
||||||
timestamp: "2026-02-27T12:00:00.000Z",
|
|
||||||
from: { hash: RECIPIENT },
|
|
||||||
to: { hash: ADDRESS, is_contract: false },
|
|
||||||
value: "1000000000000000000",
|
|
||||||
method: null,
|
|
||||||
status: "ok",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Etherscan's page for RECIPIENT, which labels it a phishing address.
|
|
||||||
const PHISHING_PAGE =
|
|
||||||
"<html><head><title>Fake_Phishing1 | Address: " +
|
|
||||||
RECIPIENT +
|
|
||||||
" | Etherscan</title></head><body></body></html>";
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a new profile", () => {
|
|
||||||
test("has both settings off", () => {
|
|
||||||
expect(DEFAULT_STATE.useBlockscout).toBe(false);
|
|
||||||
expect(DEFAULT_STATE.checkRecipientsOnEtherscan).toBe(false);
|
|
||||||
const loaded = normalizePersisted(undefined);
|
|
||||||
expect(loaded.useBlockscout).toBe(false);
|
|
||||||
expect(loaded.checkRecipientsOnEtherscan).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps either setting the user turned on", () => {
|
|
||||||
const loaded = normalizePersisted({
|
|
||||||
useBlockscout: true,
|
|
||||||
checkRecipientsOnEtherscan: true,
|
|
||||||
});
|
|
||||||
expect(loaded.useBlockscout).toBe(true);
|
|
||||||
expect(loaded.checkRecipientsOnEtherscan).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("where each request is made", () => {
|
|
||||||
// Every URL that fetch() was asked for, in order.
|
|
||||||
let asked;
|
|
||||||
|
|
||||||
// What Blockscout and Etherscan answer: USDC among ADDRESS's tokens,
|
|
||||||
// HISTORY_ITEM in its history, and PHISHING_PAGE for RECIPIENT.
|
|
||||||
function answer(url) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => {
|
|
||||||
if (url.endsWith("/token-balances")) return [USDC_ROW];
|
|
||||||
if (url.endsWith("/transactions")) {
|
|
||||||
return { items: [HISTORY_ITEM] };
|
|
||||||
}
|
|
||||||
return { items: [] };
|
|
||||||
},
|
|
||||||
text: async () => PHISHING_PAGE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recordAndAnswer(url) {
|
|
||||||
asked.push(String(url));
|
|
||||||
return answer(String(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
asked = [];
|
|
||||||
globalThis.fetch = jest.fn(recordAndAnswer);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
delete globalThis.chrome;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Storage holds `profile` as the stored profile, or nothing when it is
|
|
||||||
// undefined.
|
|
||||||
function storing(profile) {
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: makeStorageStub(
|
|
||||||
profile === undefined ? {} : { autistmask: profile },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => {
|
|
||||||
storing({ useBlockscout: false });
|
|
||||||
expect(await fetchTokenBalances(ADDRESS, BLOCKSCOUT, [])).toEqual([]);
|
|
||||||
expect(asked).toEqual([]);
|
|
||||||
|
|
||||||
storing({ useBlockscout: true });
|
|
||||||
const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, []);
|
|
||||||
expect(rows.map((row) => row.symbol)).toEqual(["USDC"]);
|
|
||||||
expect(asked).toEqual([
|
|
||||||
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the transaction history is asked for only while Use Blockscout is on", async () => {
|
|
||||||
storing({ useBlockscout: false });
|
|
||||||
expect(
|
|
||||||
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1"),
|
|
||||||
).toEqual([]);
|
|
||||||
expect(asked).toEqual([]);
|
|
||||||
|
|
||||||
storing({ useBlockscout: true });
|
|
||||||
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1");
|
|
||||||
expect(asked.sort()).toEqual([
|
|
||||||
BLOCKSCOUT +
|
|
||||||
"/addresses/" +
|
|
||||||
ADDRESS +
|
|
||||||
"/token-transfers?type=ERC-20",
|
|
||||||
BLOCKSCOUT + "/addresses/" + ADDRESS + "/transactions",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the Etherscan label check is made only while the stored profile has Check recipients on Etherscan on", async () => {
|
|
||||||
const provider = {
|
|
||||||
getCode: async () => "0x",
|
|
||||||
getTransactionCount: async () => 1,
|
|
||||||
getBalance: async () => 0n,
|
|
||||||
};
|
|
||||||
const checkWith = async (profile) => {
|
|
||||||
storing(profile);
|
|
||||||
await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" });
|
|
||||||
};
|
|
||||||
|
|
||||||
await checkWith(undefined);
|
|
||||||
await checkWith({ checkRecipientsOnEtherscan: false });
|
|
||||||
// Only a stored true is on, as when the profile is loaded.
|
|
||||||
await checkWith({ checkRecipientsOnEtherscan: "true" });
|
|
||||||
await checkWith({ checkRecipientsOnEtherscan: 42 });
|
|
||||||
expect(asked).toEqual([]);
|
|
||||||
|
|
||||||
await checkWith({ checkRecipientsOnEtherscan: true });
|
|
||||||
expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Asks with `setting` on in storage. Once every request is out, storage
|
|
||||||
// holds `after` for the setting, as when the user changes it here or on
|
|
||||||
// another page. Only then does each request get its answer.
|
|
||||||
async function answeredAfter(setting, after, ask) {
|
|
||||||
storing({ [setting]: true });
|
|
||||||
let release;
|
|
||||||
const held = new Promise((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
let requested;
|
|
||||||
const out = new Promise((resolve) => {
|
|
||||||
requested = resolve;
|
|
||||||
});
|
|
||||||
globalThis.fetch = jest.fn(async (url) => {
|
|
||||||
requested();
|
|
||||||
await held;
|
|
||||||
return recordAndAnswer(url);
|
|
||||||
});
|
|
||||||
const result = ask();
|
|
||||||
await out;
|
|
||||||
globalThis.chrome.storage.write("autistmask", { [setting]: after });
|
|
||||||
release();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One row per request, giving:
|
|
||||||
//
|
|
||||||
// - its name
|
|
||||||
// - the setting that gates it
|
|
||||||
// - how to make it
|
|
||||||
// - its answer while that setting is off
|
|
||||||
test.each([
|
|
||||||
[
|
|
||||||
"Blockscout's token balances",
|
|
||||||
"useBlockscout",
|
|
||||||
() => fetchTokenBalances(ADDRESS, BLOCKSCOUT, []),
|
|
||||||
[],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"the transaction history",
|
|
||||||
"useBlockscout",
|
|
||||||
() => fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1"),
|
|
||||||
[],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"the Etherscan label check",
|
|
||||||
"checkRecipientsOnEtherscan",
|
|
||||||
() => checkEtherscanLabel(RECIPIENT, "0x1"),
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
"%s keeps an answer that lands while its setting is on, and drops one that lands after it went off",
|
|
||||||
async (_what, setting, ask, dropped) => {
|
|
||||||
expect(await answeredAfter(setting, true, ask)).not.toEqual(
|
|
||||||
dropped,
|
|
||||||
);
|
|
||||||
expect(await answeredAfter(setting, false, ask)).toEqual(dropped);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("a balance refresh", () => {
|
|
||||||
// The RPC endpoint answers every balance with zero. The ENS reverse
|
|
||||||
// lookup makes the only other calls, and it finds no resolver.
|
|
||||||
beforeEach(() => {
|
|
||||||
FetchRequest.registerGetUrl(async (req) => {
|
|
||||||
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
|
|
||||||
const reply = (r) => ({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: r.id,
|
|
||||||
result: r.method === "eth_getBalance" ? "0x0" : ZERO_WORD,
|
|
||||||
});
|
|
||||||
const answer = Array.isArray(body)
|
|
||||||
? body.map(reply)
|
|
||||||
: reply(body);
|
|
||||||
return {
|
|
||||||
statusCode: 200,
|
|
||||||
statusMessage: "OK",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: Buffer.from(JSON.stringify(answer)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
|
||||||
});
|
|
||||||
|
|
||||||
// An address refreshed while "Use Blockscout" was on. It holds the USDC
|
|
||||||
// that Blockscout reported then.
|
|
||||||
function addressWithUsdc() {
|
|
||||||
return {
|
|
||||||
address: ADDRESS,
|
|
||||||
tokenBalances: [
|
|
||||||
{
|
|
||||||
address: USDC_ROW.token.address_hash,
|
|
||||||
name: "USD Coin",
|
|
||||||
symbol: "USDC",
|
|
||||||
decimals: 6,
|
|
||||||
balance: "5.0",
|
|
||||||
holders: 50000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh(addr, useBlockscout) {
|
|
||||||
storing({ useBlockscout });
|
|
||||||
await refreshBalances(
|
|
||||||
[{ addresses: [addr] }],
|
|
||||||
RPC_URL,
|
|
||||||
BLOCKSCOUT,
|
|
||||||
[],
|
|
||||||
"mainnet",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("asks Blockscout nothing while Use Blockscout is off, and then lists no token the user has not tracked", async () => {
|
|
||||||
const addr = addressWithUsdc();
|
|
||||||
await refresh(addr, false);
|
|
||||||
expect(asked).toEqual([]);
|
|
||||||
expect(addr.tokenBalances).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("asks Blockscout for the token balances while Use Blockscout is on", async () => {
|
|
||||||
const addr = addressWithUsdc();
|
|
||||||
await refresh(addr, true);
|
|
||||||
expect(asked).toEqual([
|
|
||||||
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
|
|
||||||
]);
|
|
||||||
expect(addr.tokenBalances.map((row) => row.symbol)).toEqual([
|
|
||||||
"USDC",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the popup", () => {
|
|
||||||
// Every URL the booted page handed to fetch().
|
|
||||||
function fetched() {
|
|
||||||
return globalThis.fetch.mock.calls.map((call) => String(call[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function blockscoutOrEtherscan(url) {
|
|
||||||
return url.includes("blockscout") || url.includes("etherscan");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// The screens below log their failed RPC reads; the stub node in
|
|
||||||
// popupBoot.js answers none.
|
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.node("home-tx-list").innerHTML).toContain(
|
|
||||||
escapeHtml(HISTORY_OFF_TEXT),
|
|
||||||
);
|
|
||||||
expect(page.hidden("home-tokens-note")).toBe(false);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
const on = await bootPopup(
|
|
||||||
unversionedValidProfile({ useBlockscout: true }),
|
|
||||||
);
|
|
||||||
expect(on.node("home-tx-list").innerHTML).toContain(
|
|
||||||
"No transactions found.",
|
|
||||||
);
|
|
||||||
expect(on.hidden("home-tokens-note")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The transaction screen, as a reopened popup draws it.
|
|
||||||
function onTransactionScreen(extra) {
|
|
||||||
return unversionedValidProfile({
|
|
||||||
currentView: "transaction",
|
|
||||||
viewStack: ["main"],
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
viewData: {
|
|
||||||
tx: {
|
|
||||||
hash: TX_HASH,
|
|
||||||
from: ADDRESS,
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "1.0000",
|
|
||||||
exactValue: "1.0",
|
|
||||||
symbol: "ETH",
|
|
||||||
timestamp: 1790000000,
|
|
||||||
isError: false,
|
|
||||||
direction: "sent",
|
|
||||||
directionLabel: "Sent",
|
|
||||||
contractAddress: null,
|
|
||||||
chainId: "0x1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TransactionDetail asks Blockscout for the transaction only while Use Blockscout is on", async () => {
|
|
||||||
const page = await bootPopup(onTransactionScreen());
|
|
||||||
expect(page.visibleViews()).toEqual(["transaction"]);
|
|
||||||
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
|
|
||||||
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
const on = await bootPopup(
|
|
||||||
onTransactionScreen({ useBlockscout: true }),
|
|
||||||
);
|
|
||||||
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
|
|
||||||
BLOCKSCOUT + "/transactions/" + TX_HASH,
|
|
||||||
]);
|
|
||||||
expect(on.hidden("tx-detail-blockscout-note")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Another page turns "Use Blockscout" off while TransactionDetail's request
|
|
||||||
// to Blockscout is out. This page draws TransactionDetail again with its
|
|
||||||
// line, and only then does Blockscout answer.
|
|
||||||
test("TransactionDetail shows nothing from an answer that lands after another page turned Use Blockscout off", async () => {
|
|
||||||
const page = await bootPopup(
|
|
||||||
onTransactionScreen({ useBlockscout: true }),
|
|
||||||
);
|
|
||||||
let release;
|
|
||||||
const held = new Promise((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
globalThis.fetch = jest.fn(async () => {
|
|
||||||
await held;
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
block_number: 21000000,
|
|
||||||
fee: { value: "21000000000000" },
|
|
||||||
raw_input: "0x12345678",
|
|
||||||
method: "doSomething",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
require("../src/popup/views/transactionDetail").render();
|
|
||||||
await page.settle();
|
|
||||||
expect(fetched()).toEqual([BLOCKSCOUT + "/transactions/" + TX_HASH]);
|
|
||||||
|
|
||||||
await anotherPageAnnounces(page, { useBlockscout: false });
|
|
||||||
release();
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["transaction"]);
|
|
||||||
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
|
|
||||||
for (const id of [
|
|
||||||
"tx-detail-block-section",
|
|
||||||
"tx-detail-network-section",
|
|
||||||
"tx-detail-calldata-section",
|
|
||||||
"tx-detail-rawdata-section",
|
|
||||||
]) {
|
|
||||||
expect(page.hidden(id)).toBe(true);
|
|
||||||
}
|
|
||||||
expect(fetched()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a
|
|
||||||
// reopened popup draws it.
|
|
||||||
function onConfirmScreen(extra) {
|
|
||||||
return unversionedValidProfile({
|
|
||||||
currentView: "confirm-tx",
|
|
||||||
viewStack: ["main"],
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
viewData: {
|
|
||||||
pendingTx: {
|
|
||||||
token: "ETH",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: RECIPIENT,
|
|
||||||
amount: "0.1",
|
|
||||||
chainId: "0x1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ConfirmTx asks Etherscan about the recipient only while Check recipients on Etherscan is on", async () => {
|
|
||||||
const page = await bootPopup(onConfirmScreen());
|
|
||||||
expect(page.visibleViews()).toEqual(["confirm-tx"]);
|
|
||||||
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
|
|
||||||
expect(page.hidden("confirm-etherscan-note")).toBe(false);
|
|
||||||
expect(page.hidden("confirm-etherscan-warning")).toBe(true);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
const on = await bootPopup(
|
|
||||||
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
|
|
||||||
);
|
|
||||||
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
|
|
||||||
"https://etherscan.io/address/" + RECIPIENT,
|
|
||||||
]);
|
|
||||||
expect(on.hidden("confirm-etherscan-note")).toBe(true);
|
|
||||||
expect(on.hidden("confirm-etherscan-warning")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ticks or unticks a checkbox on the page, as the user does.
|
|
||||||
async function setBox(page, id, checked) {
|
|
||||||
const box = page.node(id);
|
|
||||||
box.checked = checked;
|
|
||||||
for (const fn of box.listeners.change) await fn();
|
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Welcome offers both settings unticked, and ticking one there stores it", async () => {
|
|
||||||
const page = await bootPopup(undefined);
|
|
||||||
expect(page.visibleViews()).toEqual(["welcome"]);
|
|
||||||
expect(page.node("welcome-use-blockscout").checked).toBe(false);
|
|
||||||
expect(page.node("welcome-check-recipients-on-etherscan").checked).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
await setBox(page, "welcome-use-blockscout", true);
|
|
||||||
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
|
|
||||||
await setBox(page, "welcome-check-recipients-on-etherscan", true);
|
|
||||||
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Settings shows both settings unticked, and ticking one there stores it", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
expect(page.node("settings-use-blockscout").checked).toBe(false);
|
|
||||||
expect(
|
|
||||||
page.node("settings-check-recipients-on-etherscan").checked,
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
await setBox(page, "settings-use-blockscout", true);
|
|
||||||
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
|
|
||||||
await setBox(page, "settings-check-recipients-on-etherscan", true);
|
|
||||||
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A token the user tracks, and an untracked one that Blockscout reported.
|
|
||||||
// Both are written in lower case, because the balance rows' and the
|
|
||||||
// dropdown's token ids are compared in lower case below.
|
|
||||||
const TRACKED = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
||||||
const UNTRACKED = TOKEN_ADDRESS.toLowerCase();
|
|
||||||
const SECOND_ADDRESS = "0x3333333333333333333333333333333333333333";
|
|
||||||
|
|
||||||
function tokenRow(address, symbol) {
|
|
||||||
return {
|
|
||||||
address,
|
|
||||||
name: symbol,
|
|
||||||
symbol,
|
|
||||||
decimals: 18,
|
|
||||||
balance: "5.0",
|
|
||||||
holders: 50000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddressDetail of the first of two addresses, as a reopened popup draws
|
|
||||||
// it. Both addresses hold the tracked token. While "Use Blockscout" is on,
|
|
||||||
// both hold the untracked one too.
|
|
||||||
function onAddressDetail(useBlockscout) {
|
|
||||||
const profile = unversionedValidProfile({
|
|
||||||
useBlockscout,
|
|
||||||
currentView: "address",
|
|
||||||
viewStack: ["main"],
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
trackedTokens: [
|
|
||||||
{ address: TRACKED, name: "TRK", symbol: "TRK", decimals: 18 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const wallet = profile.wallets[0];
|
|
||||||
wallet.nextIndex = 2;
|
|
||||||
wallet.addresses.push({ address: SECOND_ADDRESS, balance: "0.0" });
|
|
||||||
for (const addr of wallet.addresses) {
|
|
||||||
addr.tokenBalances = useBlockscout
|
|
||||||
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
|
|
||||||
: [tokenRow(TRACKED, "TRK")];
|
|
||||||
}
|
|
||||||
return profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The token ids of the options created after the first `from` elements.
|
|
||||||
// The page stub keeps no children, so this reads the options for Send's
|
|
||||||
// dropdown from the elements the page created.
|
|
||||||
function optionsSince(page, from) {
|
|
||||||
return page.document.created
|
|
||||||
.slice(from)
|
|
||||||
.filter((el) => el.tagName === "OPTION")
|
|
||||||
.map((el) => el.value.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
test("unticking Use Blockscout from AddressDetail's gear takes the untracked token off AddressDetail, Send's dropdown and Home on Back", async () => {
|
|
||||||
const page = await bootPopup(onAddressDetail(true));
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
expect(page.node("address-balances").innerHTML).toContain(UNTRACKED);
|
|
||||||
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await setBox(page, "settings-use-blockscout", false);
|
|
||||||
const from = page.document.created.length;
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
const balances = page.node("address-balances").innerHTML;
|
|
||||||
expect(balances).toContain(TRACKED);
|
|
||||||
expect(balances).not.toContain(UNTRACKED);
|
|
||||||
expect(page.hidden("address-tokens-note")).toBe(false);
|
|
||||||
expect(optionsSince(page, from)).toEqual([TRACKED]);
|
|
||||||
|
|
||||||
await page.click("btn-send");
|
|
||||||
expect(page.visibleViews()).toEqual(["send"]);
|
|
||||||
expect(page.hidden("send-token-dropdown")).toBe(false);
|
|
||||||
expect(page.hidden("send-tokens-note")).toBe(false);
|
|
||||||
|
|
||||||
await page.click("btn-send-back");
|
|
||||||
await page.click("btn-address-back");
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
|
||||||
const home = page.node("wallet-list").innerHTML;
|
|
||||||
expect(home).toContain(TRACKED);
|
|
||||||
expect(home).not.toContain(UNTRACKED);
|
|
||||||
|
|
||||||
const stored = page.storage.read("autistmask");
|
|
||||||
for (const addr of stored.wallets[0].addresses) {
|
|
||||||
expect(
|
|
||||||
addr.tokenBalances.map((row) => row.address.toLowerCase()),
|
|
||||||
).toEqual([TRACKED]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send's dropdown was filled under the old setting, and Back cannot draw
|
|
||||||
// Send again. So Back lands on the screen under it.
|
|
||||||
test.each([
|
|
||||||
["unticking", true],
|
|
||||||
["ticking", false],
|
|
||||||
])(
|
|
||||||
"%s Use Blockscout from Send's gear lands Back on AddressDetail",
|
|
||||||
async (_how, before) => {
|
|
||||||
const page = await bootPopup(onAddressDetail(before));
|
|
||||||
await page.click("btn-send");
|
|
||||||
expect(page.visibleViews()).toEqual(["send"]);
|
|
||||||
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await setBox(page, "settings-use-blockscout", !before);
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
expect(page.hidden("address-tokens-note")).toBe(!before);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("Send says under its token dropdown that only tracked tokens are listed while Use Blockscout is off", async () => {
|
|
||||||
const off = await bootPopup(onAddressDetail(false));
|
|
||||||
await off.click("btn-send");
|
|
||||||
expect(off.hidden("send-token-dropdown")).toBe(false);
|
|
||||||
expect(off.hidden("send-tokens-note")).toBe(false);
|
|
||||||
cleanupPopup();
|
|
||||||
|
|
||||||
const on = await bootPopup(onAddressDetail(true));
|
|
||||||
await on.click("btn-send");
|
|
||||||
expect(on.hidden("send-token-dropdown")).toBe(false);
|
|
||||||
expect(on.hidden("send-tokens-note")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Send opened from AddressToken shows neither the dropdown nor its line", async () => {
|
|
||||||
const page = await bootPopup({
|
|
||||||
...onAddressDetail(false),
|
|
||||||
currentView: "address-token",
|
|
||||||
viewStack: ["main", "address"],
|
|
||||||
selectedToken: "ETH",
|
|
||||||
});
|
|
||||||
expect(page.visibleViews()).toEqual(["address-token"]);
|
|
||||||
await page.click("btn-address-token-send");
|
|
||||||
expect(page.visibleViews()).toEqual(["send"]);
|
|
||||||
expect(page.hidden("send-token-dropdown")).toBe(true);
|
|
||||||
expect(page.hidden("send-token-static")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("DeleteAddress says only tracked tokens are listed while Use Blockscout is off", async () => {
|
|
||||||
for (const useBlockscout of [false, true]) {
|
|
||||||
const page = await bootPopup(onAddressDetail(useBlockscout));
|
|
||||||
require("../src/popup/views/deleteAddress").show(0, 1);
|
|
||||||
expect(page.visibleViews()).toEqual(["delete-address-confirm"]);
|
|
||||||
expect(page.hidden("delete-address-tokens-note")).toBe(
|
|
||||||
useBlockscout,
|
|
||||||
);
|
|
||||||
cleanupPopup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The token ids of the first address's balances in the stored profile.
|
|
||||||
function storedTokens(page) {
|
|
||||||
return page.storage
|
|
||||||
.read("autistmask")
|
|
||||||
.wallets[0].addresses[0].tokenBalances.map((row) =>
|
|
||||||
row.address.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turning the setting off dropped the untracked token. A refresh that
|
|
||||||
// started while the setting was on still saves its balances when it
|
|
||||||
// returns, but not that token. The same refresh in the background is in
|
|
||||||
// tests/backgroundStateIsolation.test.js.
|
|
||||||
test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => {
|
|
||||||
const page = await bootPopup(onAddressDetail(true));
|
|
||||||
const { refreshBalances: held } = require("../src/shared/balances");
|
|
||||||
let answer;
|
|
||||||
held.mockImplementationOnce(async (wallets) => {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
answer = resolve;
|
|
||||||
});
|
|
||||||
wallets[0].addresses[0].balance = "7.0";
|
|
||||||
wallets[0].addresses[0].tokenBalances = [
|
|
||||||
tokenRow(TRACKED, "TRK"),
|
|
||||||
tokenRow(UNTRACKED, "TST"),
|
|
||||||
];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const ticking = page.tick();
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
await page.click("btn-settings");
|
|
||||||
await setBox(page, "settings-use-blockscout", false);
|
|
||||||
answer();
|
|
||||||
await ticking;
|
|
||||||
|
|
||||||
expect(
|
|
||||||
page.storage.read("autistmask").wallets[0].addresses[0].balance,
|
|
||||||
).toBe("7.0");
|
|
||||||
expect(storedTokens(page)).toEqual([TRACKED]);
|
|
||||||
await page.click("btn-settings-back");
|
|
||||||
expect(page.node("address-balances").innerHTML).not.toContain(
|
|
||||||
UNTRACKED,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Another page saves `change` over the stored profile. The browser is not
|
|
||||||
// made to tell this page, so this page sees the change only by reading the
|
|
||||||
// stored profile.
|
|
||||||
function anotherPageSaves(page, change) {
|
|
||||||
page.storage.write("autistmask", {
|
|
||||||
...page.storage.read("autistmask"),
|
|
||||||
...change,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["off", true, false],
|
|
||||||
["on", false, true],
|
|
||||||
])(
|
|
||||||
"after another page turns Use Blockscout %s, this page takes the change and its next refresh is saved",
|
|
||||||
async (_how, before, after) => {
|
|
||||||
const page = await bootPopup(onAddressDetail(before));
|
|
||||||
// The refresh answers as it does under the stored setting.
|
|
||||||
const { refreshBalances: held } = require("../src/shared/balances");
|
|
||||||
held.mockImplementationOnce(async (wallets) => {
|
|
||||||
const addr = wallets[0].addresses[0];
|
|
||||||
addr.balance = "7.0";
|
|
||||||
addr.tokenBalances = after
|
|
||||||
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
|
|
||||||
: [tokenRow(TRACKED, "TRK")];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
anotherPageSaves(page, { useBlockscout: after });
|
|
||||||
await page.tick();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
page.storage.read("autistmask").wallets[0].addresses[0].balance,
|
|
||||||
).toBe("7.0");
|
|
||||||
expect(storedTokens(page)).toEqual(
|
|
||||||
after ? [TRACKED, UNTRACKED] : [TRACKED],
|
|
||||||
);
|
|
||||||
expect(page.hidden("address-tokens-note")).toBe(after);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["off", true, false],
|
|
||||||
["on", false, true],
|
|
||||||
])(
|
|
||||||
"after another page turns Check recipients on Etherscan %s, this page's next Etherscan check follows the stored setting",
|
|
||||||
async (_how, before, after) => {
|
|
||||||
const page = await bootPopup(
|
|
||||||
onConfirmScreen({ checkRecipientsOnEtherscan: before }),
|
|
||||||
);
|
|
||||||
anotherPageSaves(page, { checkRecipientsOnEtherscan: after });
|
|
||||||
const from = fetched().length;
|
|
||||||
require("../src/popup/views/confirmTx").show({
|
|
||||||
token: "ETH",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: RECIPIENT,
|
|
||||||
amount: "0.1",
|
|
||||||
chainId: "0x1",
|
|
||||||
});
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
// The page also takes the change and draws ConfirmTx again, which
|
|
||||||
// checks once more. So the test counts each URL once, however often
|
|
||||||
// it was asked for.
|
|
||||||
const asked = new Set(
|
|
||||||
fetched().slice(from).filter(blockscoutOrEtherscan),
|
|
||||||
);
|
|
||||||
expect([...asked]).toEqual(
|
|
||||||
after ? ["https://etherscan.io/address/" + RECIPIENT] : [],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Another page's save of the whole profile with `change`, which the
|
|
||||||
// browser announces to this page as it announces any write.
|
|
||||||
async function anotherPageAnnounces(page, change) {
|
|
||||||
await page.storage.local.set({
|
|
||||||
autistmask: { ...page.storage.read("autistmask"), ...change },
|
|
||||||
});
|
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a page open on AddressDetail draws it again when another page turns Use Blockscout off", async () => {
|
|
||||||
const page = await bootPopup(onAddressDetail(true));
|
|
||||||
expect(page.hidden("address-tokens-note")).toBe(true);
|
|
||||||
|
|
||||||
await anotherPageAnnounces(page, { useBlockscout: false });
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["address"]);
|
|
||||||
expect(page.hidden("address-tokens-note")).toBe(false);
|
|
||||||
expect(page.node("address-balances").innerHTML).not.toContain(
|
|
||||||
UNTRACKED,
|
|
||||||
);
|
|
||||||
expect(storedTokens(page)).toEqual([TRACKED]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page open on ConfirmTx draws it again when another page turns Check recipients on Etherscan off", async () => {
|
|
||||||
const page = await bootPopup(
|
|
||||||
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
|
|
||||||
);
|
|
||||||
expect(page.hidden("confirm-etherscan-note")).toBe(true);
|
|
||||||
|
|
||||||
await anotherPageAnnounces(page, { checkRecipientsOnEtherscan: false });
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["confirm-tx"]);
|
|
||||||
expect(page.hidden("confirm-etherscan-note")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+38
-327
@@ -1,24 +1,16 @@
|
|||||||
// Who may move the active chain, and when.
|
// Who may move the active chain.
|
||||||
//
|
//
|
||||||
// wallet_switchEthereumChain used to be answered for any origin at all, with
|
// wallet_switchEthereumChain used to be answered for any origin at all, with
|
||||||
// no connection check and no prompt, so a page the user had never connected
|
// no connection check and no prompt, so a page the user had never connected
|
||||||
// to could clear the [TESTNET] banner under someone who believed they were
|
// to could clear the [TESTNET] banner under someone who believed they were
|
||||||
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). Gated on the
|
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
|
||||||
// connection, a connected site could still move the wallet between mainnet and
|
// is asserted as a refusal to ACT — the state unmoved and no chainChanged
|
||||||
// Sepolia without asking. Only the user switches the network: a connected
|
// broadcast — because an error code alone would not distinguish a gate from
|
||||||
// site's request opens a prompt, and nothing changes unless the user approves
|
// a switch that happened and then reported a failure.
|
||||||
// it there (https://git.eeqj.de/sneak/AutistMask/issues/408).
|
|
||||||
//
|
//
|
||||||
// Every refusal is asserted as a refusal to ACT — the stored record unmoved
|
// The endpoint half of that issue lives in tests/networkEndpoints.test.js,
|
||||||
// and no chainChanged broadcast — because an error code alone would not
|
// which covers the popup's chain switch; this file covers the background's,
|
||||||
// distinguish a refusal from a switch that happened and then reported a
|
// which goes through storage rather than the shared state singleton.
|
||||||
// failure.
|
|
||||||
//
|
|
||||||
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
|
|
||||||
// covers the popup's chain switch; this file covers the background's, which
|
|
||||||
// goes through storage rather than the shared state singleton. The last block
|
|
||||||
// covers what the background tells open tabs when the user switches the
|
|
||||||
// network in Settings.
|
|
||||||
|
|
||||||
const { networkById } = require("../src/shared/networks");
|
const { networkById } = require("../src/shared/networks");
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
@@ -29,23 +21,18 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
const STRANGER_ORIGIN = "https://stranger.example";
|
const STRANGER_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
|
|
||||||
// The user's own node, so a switch that happens is visible as the loss of it.
|
// The user's own node, so a switch that happens is visible as the loss of it.
|
||||||
const CUSTOM_RPC = "http://127.0.0.1:8545";
|
const CUSTOM_RPC = "http://127.0.0.1:8545";
|
||||||
|
|
||||||
// A balance a switch would clear, so a switch that happens is visible here too.
|
|
||||||
function walletFixture() {
|
function walletFixture() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "Wallet 1",
|
name: "Wallet 1",
|
||||||
type: "hd",
|
type: "hd",
|
||||||
addresses: [
|
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
||||||
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -60,6 +47,10 @@ afterEach(() => {
|
|||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The gate: which origins the background will switch the chain for.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Load the background worker against stubbed browser APIs, with the real
|
// Load the background worker against stubbed browser APIs, with the real
|
||||||
// chain-switch and persistence modules behind it, and return the handles to
|
// chain-switch and persistence modules behind it, and return the handles to
|
||||||
// drive it.
|
// drive it.
|
||||||
@@ -100,46 +91,30 @@ function loadBackground() {
|
|||||||
const storage = makeStorageStub({ autistmask: persisted });
|
const storage = makeStorageStub({ autistmask: persisted });
|
||||||
|
|
||||||
let messageListener = null;
|
let messageListener = null;
|
||||||
let connectListener = null;
|
|
||||||
let windowRemovedListener = null;
|
|
||||||
// The URL of every approval window the background opened. The approval id
|
|
||||||
// is in it, and that is how the popup learns which approval it answers.
|
|
||||||
const opened = [];
|
|
||||||
// Every message the background pushed at a content script. chainChanged
|
// Every message the background pushed at a content script. chainChanged
|
||||||
// is what tells a page the wallet moved, so a switch is visible here as
|
// is what tells a page the wallet moved, so an ungated switch is visible
|
||||||
// well as in the state.
|
// here as well as in the state.
|
||||||
const toTabs = [];
|
const toTabs = [];
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
storage,
|
storage,
|
||||||
runtime: {
|
runtime: {
|
||||||
getURL: (path) => EXT_URL + path,
|
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||||
onMessage: {
|
onMessage: {
|
||||||
addListener: (fn) => {
|
addListener: (fn) => {
|
||||||
messageListener = fn;
|
messageListener = fn;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onConnect: {
|
onConnect: { addListener: () => {} },
|
||||||
addListener: (fn) => {
|
|
||||||
connectListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
getLastFocused: (cb) => cb(null),
|
getLastFocused: (cb) => cb(null),
|
||||||
create: (options, cb) => {
|
create: (options, cb) => cb({ id: 1 }),
|
||||||
opened.push(options.url);
|
|
||||||
cb({ id: opened.length });
|
|
||||||
},
|
|
||||||
remove: (id, cb) => {
|
remove: (id, cb) => {
|
||||||
if (cb) cb();
|
if (cb) cb();
|
||||||
},
|
},
|
||||||
onRemoved: {
|
onRemoved: { addListener: () => {} },
|
||||||
addListener: (fn) => {
|
|
||||||
windowRemovedListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
query: (queryInfo, cb) => cb([{ id: 1 }]),
|
query: (queryInfo, cb) => cb([{ id: 1 }]),
|
||||||
@@ -153,8 +128,6 @@ function loadBackground() {
|
|||||||
|
|
||||||
require("../src/background/index");
|
require("../src/background/index");
|
||||||
|
|
||||||
// A page's request. Its answer is read with result(), which is null for as
|
|
||||||
// long as the request is waiting on the user.
|
|
||||||
async function switchChain(chainId, origin) {
|
async function switchChain(chainId, origin) {
|
||||||
let result = null;
|
let result = null;
|
||||||
messageListener(
|
messageListener(
|
||||||
@@ -169,155 +142,56 @@ function loadBackground() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await settle();
|
await settle();
|
||||||
return { result: () => result };
|
return result;
|
||||||
}
|
|
||||||
|
|
||||||
function promptId() {
|
|
||||||
return new URL(opened[opened.length - 1]).searchParams.get("approval");
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the popup is told to show for the prompt.
|
|
||||||
function describePrompt() {
|
|
||||||
let reply = null;
|
|
||||||
messageListener(
|
|
||||||
{ type: "AUTISTMASK_GET_APPROVAL", id: promptId() },
|
|
||||||
{ url: EXT_URL + "src/popup/index.html" },
|
|
||||||
(r) => {
|
|
||||||
reply = r;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user's answer, as the popup sends it: on the port named for the
|
|
||||||
// approval, from the extension's own page, and then the window closes.
|
|
||||||
async function answerPrompt(approved) {
|
|
||||||
const onMessage = [];
|
|
||||||
const onDisconnect = [];
|
|
||||||
const port = {
|
|
||||||
name: "approval:" + promptId(),
|
|
||||||
sender: { url: EXT_URL + "src/popup/index.html" },
|
|
||||||
onMessage: { addListener: (fn) => onMessage.push(fn) },
|
|
||||||
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
|
|
||||||
};
|
|
||||||
connectListener(port);
|
|
||||||
for (const fn of onMessage) {
|
|
||||||
fn(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_APPROVAL_DECISION",
|
|
||||||
approved,
|
|
||||||
remember: false,
|
|
||||||
},
|
|
||||||
port,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const fn of onDisconnect) fn(port);
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user closes the prompt window without answering it.
|
|
||||||
async function closePrompt() {
|
|
||||||
windowRemovedListener(opened.length);
|
|
||||||
await settle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
switchChain,
|
switchChain,
|
||||||
describePrompt,
|
|
||||||
answerPrompt,
|
|
||||||
closePrompt,
|
|
||||||
opened,
|
|
||||||
// A message to the background from `sender`, and its answer.
|
|
||||||
send: (msg, sender) => {
|
|
||||||
let reply = null;
|
|
||||||
messageListener(msg, sender, (r) => {
|
|
||||||
reply = r;
|
|
||||||
});
|
|
||||||
return reply;
|
|
||||||
},
|
|
||||||
walletState: () => storage.read("autistmask"),
|
walletState: () => storage.read("autistmask"),
|
||||||
chainChangedEvents: () =>
|
chainChangedEvents: () =>
|
||||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
|
|
||||||
|
|
||||||
describe("wallet_switchEthereumChain is gated on the connection", () => {
|
describe("wallet_switchEthereumChain is gated on the connection", () => {
|
||||||
test("an origin the wallet was never connected to is refused with 4100", async () => {
|
test("an origin the wallet was never connected to is refused with 4100", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const before = bg.walletState();
|
|
||||||
|
|
||||||
const request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
|
const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
|
||||||
|
|
||||||
expect(request.result().error).toEqual({
|
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
|
||||||
code: 4100,
|
expect(result.result).toBeUndefined();
|
||||||
message: "Unauthorized",
|
|
||||||
});
|
|
||||||
expect(request.result().result).toBeUndefined();
|
|
||||||
// The refusal has to be a refusal to ACT, not just an error string:
|
// The refusal has to be a refusal to ACT, not just an error string:
|
||||||
// the wallet is still on mainnet, still on the user's own node, and
|
// the wallet is still on mainnet, still on the user's own node, and
|
||||||
// no page was told the chain moved. Nor was the user asked.
|
// no page was told the chain moved.
|
||||||
expect(bg.walletState()).toEqual(before);
|
expect(bg.walletState().networkId).toBe("mainnet");
|
||||||
|
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
expect(bg.chainChangedEvents()).toEqual([]);
|
||||||
expect(bg.opened).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an unconnected origin is refused even for the chain already active", async () => {
|
test("an unconnected origin is refused even for the chain already active", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
const request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
|
const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
|
||||||
|
|
||||||
expect(request.result().error).toEqual({
|
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
|
||||||
code: 4100,
|
|
||||||
message: "Unauthorized",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
|
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
const request = await bg.switchChain("0x89", STRANGER_ORIGIN);
|
const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
|
||||||
|
|
||||||
expect(request.result().error.code).toBe(4100);
|
expect(result.error.code).toBe(4100);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("only the user switches the network", () => {
|
|
||||||
test("a connected site's request changes nothing while the prompt is open", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const before = bg.walletState();
|
|
||||||
|
|
||||||
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
||||||
|
|
||||||
// Waiting on the user, with one prompt on screen naming the site and
|
|
||||||
// both networks.
|
|
||||||
expect(request.result()).toBeNull();
|
|
||||||
expect(bg.opened).toHaveLength(1);
|
|
||||||
expect(bg.describePrompt()).toMatchObject({
|
|
||||||
origin: CONNECTED_ORIGIN,
|
|
||||||
type: "network",
|
|
||||||
currentNetworkId: "mainnet",
|
|
||||||
requestedNetworkId: "sepolia",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The network, the endpoints and the balances are as they were, and
|
|
||||||
// no page was told otherwise.
|
|
||||||
expect(bg.walletState()).toEqual(before);
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("approving the prompt switches the network", async () => {
|
test("a connected origin switches the chain", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
||||||
await bg.answerPrompt(true);
|
|
||||||
|
|
||||||
expect(request.result()).toEqual({ result: null });
|
expect(result).toEqual({ result: null });
|
||||||
const after = bg.walletState();
|
expect(bg.walletState().networkId).toBe("sepolia");
|
||||||
expect(after.networkId).toBe("sepolia");
|
|
||||||
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
|
||||||
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([
|
expect(bg.chainChangedEvents()).toEqual([
|
||||||
{
|
{
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
@@ -327,185 +201,22 @@ describe("only the user switches the network", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejecting the prompt changes nothing and answers 4001", async () => {
|
test("a connected origin asking for an unsupported chain still gets 4902", async () => {
|
||||||
const bg = loadBackground();
|
|
||||||
const before = bg.walletState();
|
|
||||||
|
|
||||||
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
||||||
await bg.answerPrompt(false);
|
|
||||||
|
|
||||||
expect(request.result()).toEqual({ error: USER_REJECTED });
|
|
||||||
expect(bg.walletState()).toEqual(before);
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closing the prompt without answering changes nothing and answers 4001", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const before = bg.walletState();
|
|
||||||
|
|
||||||
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
||||||
await bg.closePrompt();
|
|
||||||
|
|
||||||
expect(request.result()).toEqual({ error: USER_REJECTED });
|
|
||||||
expect(bg.walletState()).toEqual(before);
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a request for the chain already active opens no prompt", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const before = bg.walletState();
|
|
||||||
|
|
||||||
const request = await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
|
|
||||||
|
|
||||||
expect(request.result()).toEqual({ result: null });
|
|
||||||
expect(bg.opened).toEqual([]);
|
|
||||||
expect(bg.walletState()).toEqual(before);
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a second request while the prompt is open is refused with -32002", async () => {
|
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
const first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
|
||||||
const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
||||||
|
|
||||||
expect(second.result().error.code).toBe(-32002);
|
expect(result.error.code).toBe(4902);
|
||||||
expect(bg.opened).toHaveLength(1);
|
|
||||||
|
|
||||||
// The first prompt still decides.
|
|
||||||
await bg.answerPrompt(true);
|
|
||||||
expect(first.result()).toEqual({ result: null });
|
|
||||||
expect(bg.walletState().networkId).toBe("sepolia");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a request for an unsupported chain still gets 4902 and no prompt", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
|
|
||||||
const request = await bg.switchChain("0x89", CONNECTED_ORIGIN);
|
|
||||||
|
|
||||||
expect(request.result().error.code).toBe(4902);
|
|
||||||
expect(bg.opened).toEqual([]);
|
|
||||||
expect(bg.walletState().networkId).toBe("mainnet");
|
expect(bg.walletState().networkId).toBe("mainnet");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an approved switch keeps the user's endpoint", async () => {
|
test("a switch by a connected origin keeps the user's endpoint", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
||||||
await bg.answerPrompt(true);
|
|
||||||
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||||
|
|
||||||
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
|
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
|
||||||
await bg.answerPrompt(true);
|
|
||||||
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switching the network in Settings tells open pages, as an approved site
|
|
||||||
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
|
|
||||||
// the real Settings view over the background's storage, with what it sends
|
|
||||||
// delivered to the background from the extension's own page.
|
|
||||||
describe("switching the network in Settings tells every open tab", () => {
|
|
||||||
const POPUP = { url: EXT_URL + "src/popup/index.html" };
|
|
||||||
|
|
||||||
// A stand-in for one DOM node: enough of an element for init() to set
|
|
||||||
// properties on it and hang listeners off it.
|
|
||||||
function fakeElement() {
|
|
||||||
return {
|
|
||||||
value: "",
|
|
||||||
checked: false,
|
|
||||||
textContent: "",
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: { add() {}, remove() {} },
|
|
||||||
listeners: {},
|
|
||||||
addEventListener(event, handler) {
|
|
||||||
this.listeners[event] = handler;
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings, opened the way the popup opens it. Returns its network
|
|
||||||
// selector.
|
|
||||||
async function openSettings(bg) {
|
|
||||||
const elements = {};
|
|
||||||
const element = (id) => (elements[id] ||= fakeElement());
|
|
||||||
jest.doMock("../src/popup/views/helpers", () => ({
|
|
||||||
$: element,
|
|
||||||
showView: () => {},
|
|
||||||
updateDebugBanner: () => {},
|
|
||||||
showFlash: () => {},
|
|
||||||
escapeHtml: (s) => s,
|
|
||||||
flashCopyFeedback: () => {},
|
|
||||||
goBack: () => {},
|
|
||||||
pushCurrentView: () => {},
|
|
||||||
onViewLeave: () => {},
|
|
||||||
VIEWS: [],
|
|
||||||
}));
|
|
||||||
global.chrome.runtime.sendMessage = (msg) => {
|
|
||||||
bg.send(msg, POPUP);
|
|
||||||
};
|
|
||||||
const { state, loadState } = require("../src/shared/state");
|
|
||||||
await loadState();
|
|
||||||
// Set by showView(), which is mocked above.
|
|
||||||
state.currentView = "settings";
|
|
||||||
require("../src/popup/views/settings").init({
|
|
||||||
pageClosed: new AbortController().signal,
|
|
||||||
});
|
|
||||||
const select = element("settings-network");
|
|
||||||
select.value = "mainnet";
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user picks `networkId` in the selector.
|
|
||||||
async function choose(select, networkId) {
|
|
||||||
select.value = networkId;
|
|
||||||
await select.listeners.change();
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.dontMock("../src/popup/views/helpers");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching to the other network sends chainChanged once, with its chain id", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const select = await openSettings(bg);
|
|
||||||
|
|
||||||
await choose(select, "sepolia");
|
|
||||||
|
|
||||||
expect(bg.walletState().networkId).toBe("sepolia");
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "chainChanged",
|
|
||||||
data: SEPOLIA.chainId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("choosing the network already active changes nothing and sends nothing", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const select = await openSettings(bg);
|
|
||||||
const before = bg.walletState();
|
|
||||||
|
|
||||||
await choose(select, "mainnet");
|
|
||||||
|
|
||||||
expect(bg.walletState()).toEqual(before);
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page cannot make the background send chainChanged", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
|
|
||||||
const reply = bg.send(
|
|
||||||
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
|
|
||||||
{ url: CONNECTED_ORIGIN + "/" },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(reply).toEqual({ error: "Unauthorized sender" });
|
|
||||||
expect(bg.chainChangedEvents()).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -73,8 +73,6 @@ function loadColdWorker(networkId, opts) {
|
|||||||
jest.doMock("../src/shared/balances", () => ({
|
jest.doMock("../src/shared/balances", () => ({
|
||||||
getProvider: () => ({}),
|
getProvider: () => ({}),
|
||||||
refreshBalances: options.refreshBalances || jest.fn(async () => {}),
|
refreshBalances: options.refreshBalances || jest.fn(async () => {}),
|
||||||
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
|
|
||||||
.dropUntrackedTokens,
|
|
||||||
}));
|
}));
|
||||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||||
isPhishingDomain: () => false,
|
isPhishingDomain: () => false,
|
||||||
|
|||||||
@@ -14,10 +14,6 @@
|
|||||||
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
|
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
|
||||||
// state module wholesale and tests/networkEndpoints.test.js always loads
|
// state module wholesale and tests/networkEndpoints.test.js always loads
|
||||||
// first, so neither can see this.
|
// first, so neither can see this.
|
||||||
//
|
|
||||||
// A site's switch happens only once the user approves it on a prompt
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/408), so every switch here is
|
|
||||||
// approved the way the popup approves one.
|
|
||||||
|
|
||||||
const { networkById } = require("../src/shared/networks");
|
const { networkById } = require("../src/shared/networks");
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
@@ -94,9 +90,6 @@ function loadColdWorker(networkId) {
|
|||||||
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
||||||
|
|
||||||
let messageListener = null;
|
let messageListener = null;
|
||||||
let connectListener = null;
|
|
||||||
// The URL of every approval window opened; the approval id is in it.
|
|
||||||
const opened = [];
|
|
||||||
const toTabs = [];
|
const toTabs = [];
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
@@ -108,19 +101,12 @@ function loadColdWorker(networkId) {
|
|||||||
messageListener = fn;
|
messageListener = fn;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onConnect: {
|
onConnect: { addListener: () => {} },
|
||||||
addListener: (fn) => {
|
|
||||||
connectListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
getLastFocused: (cb) => cb(null),
|
getLastFocused: (cb) => cb(null),
|
||||||
create: (options, cb) => {
|
create: (options, cb) => cb({ id: 1 }),
|
||||||
opened.push(options.url);
|
|
||||||
cb({ id: opened.length });
|
|
||||||
},
|
|
||||||
remove: (id, cb) => {
|
remove: (id, cb) => {
|
||||||
if (cb) cb();
|
if (cb) cb();
|
||||||
},
|
},
|
||||||
@@ -138,32 +124,6 @@ function loadColdWorker(networkId) {
|
|||||||
|
|
||||||
require("../src/background/index");
|
require("../src/background/index");
|
||||||
|
|
||||||
// The user approves the prompt the request opened, as the popup does: a
|
|
||||||
// decision on the port named for the approval, from the extension's own
|
|
||||||
// page.
|
|
||||||
function approvePrompt() {
|
|
||||||
const id = new URL(opened[opened.length - 1]).searchParams.get(
|
|
||||||
"approval",
|
|
||||||
);
|
|
||||||
let onDecision = null;
|
|
||||||
const port = {
|
|
||||||
name: "approval:" + id,
|
|
||||||
sender: { url: "chrome-extension://autistmask/src/popup/" },
|
|
||||||
onMessage: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
onDecision = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onDisconnect: { addListener: () => {} },
|
|
||||||
};
|
|
||||||
connectListener(port);
|
|
||||||
onDecision(
|
|
||||||
{ type: "AUTISTMASK_APPROVAL_DECISION", approved: true },
|
|
||||||
port,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A connected site asks for `chainId`, and the user approves it.
|
|
||||||
async function switchChain(chainId) {
|
async function switchChain(chainId) {
|
||||||
let result = null;
|
let result = null;
|
||||||
messageListener(
|
messageListener(
|
||||||
@@ -178,8 +138,6 @@ function loadColdWorker(networkId) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await settle();
|
await settle();
|
||||||
approvePrompt();
|
|
||||||
await settle();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,21 +162,17 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||||
|
|
||||||
// And it took nothing else with it. Without the load these come back
|
// And it took nothing else with it. Without the load these come back
|
||||||
// as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the
|
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
|
||||||
// extension gone, encrypted secrets included.
|
// extension gone, encrypted secrets included.
|
||||||
expect(after.wallets).toEqual(walletFixture());
|
expect(after.wallets).toEqual(walletFixture());
|
||||||
expect(after.hasWallet).toBe(true);
|
expect(after.hasWallet).toBe(true);
|
||||||
expect(after.activeAddress).toBe(ADDRESS);
|
expect(after.activeAddress).toBe(ADDRESS);
|
||||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
||||||
expect(after.networkTrackedTokens).toEqual({
|
expect(after.trackedTokens).toEqual([
|
||||||
mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||||
});
|
]);
|
||||||
expect(after.theme).toBe("dark");
|
expect(after.theme).toBe("dark");
|
||||||
|
|
||||||
// The mainnet token is tracked on mainnet only: Sepolia starts with
|
|
||||||
// none of its own.
|
|
||||||
expect(after.trackedTokens).toEqual([]);
|
|
||||||
|
|
||||||
// The user's mainnet endpoint is remembered rather than replaced by
|
// The user's mainnet endpoint is remembered rather than replaced by
|
||||||
// the public default, so switching back returns it.
|
// the public default, so switching back returns it.
|
||||||
expect(after.networkEndpoints.mainnet).toEqual({
|
expect(after.networkEndpoints.mainnet).toEqual({
|
||||||
@@ -230,9 +184,6 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
|
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
|
||||||
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
||||||
expect(bg.persisted().wallets).toEqual(walletFixture());
|
expect(bg.persisted().wallets).toEqual(walletFixture());
|
||||||
expect(bg.persisted().trackedTokens).toEqual([
|
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compares the requested chain against the stored one, not the default", async () => {
|
test("compares the requested chain against the stored one, not the default", async () => {
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
// The amount of the wallet's own send on the confirmation, wait, success and
|
|
||||||
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
|
|
||||||
//
|
|
||||||
// These screens printed the text from Send's amount field as it was typed or
|
|
||||||
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
|
|
||||||
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
|
|
||||||
// decimal places with the nonzero floor, as the approval screen does, and the
|
|
||||||
// transaction is still signed for the exact amount.
|
|
||||||
//
|
|
||||||
// Driven through the real Send screen, confirmation screen, Sign & Send and
|
|
||||||
// wait screen, with only the node, the explorer and the DOM stubbed.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// What the stub node answers, and the signed transactions it was handed. The
|
|
||||||
// token's own decimals() answers 6, as the explorer does.
|
|
||||||
const mockNode = {
|
|
||||||
balanceWei: 0n,
|
|
||||||
tokenUnits: 0n,
|
|
||||||
tokenDecimals: 6n,
|
|
||||||
broadcast: [],
|
|
||||||
broadcastError: null,
|
|
||||||
receipt: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// The token rows the stub explorer reports for the address.
|
|
||||||
const mockExplorer = { items: [] };
|
|
||||||
|
|
||||||
jest.mock("ethers", () => {
|
|
||||||
const actual = jest.requireActual("ethers");
|
|
||||||
// The selector of decimals(); every other contract call is balanceOf().
|
|
||||||
const DECIMALS = "0x313ce567";
|
|
||||||
class StubProvider {
|
|
||||||
async getBalance() {
|
|
||||||
return mockNode.balanceWei;
|
|
||||||
}
|
|
||||||
async call(tx) {
|
|
||||||
return actual.AbiCoder.defaultAbiCoder().encode(
|
|
||||||
["uint256"],
|
|
||||||
[
|
|
||||||
tx.data.startsWith(DECIMALS)
|
|
||||||
? mockNode.tokenDecimals
|
|
||||||
: mockNode.tokenUnits,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
async lookupAddress() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// 20 gwei reserved per gas.
|
|
||||||
async getFeeData() {
|
|
||||||
return {
|
|
||||||
maxFeePerGas: 20000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
gasPrice: 10000000000n,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async estimateGas() {
|
|
||||||
return 21000n;
|
|
||||||
}
|
|
||||||
async getCode() {
|
|
||||||
return "0x";
|
|
||||||
}
|
|
||||||
async getTransactionCount() {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
async getNetwork() {
|
|
||||||
return { chainId: 1n };
|
|
||||||
}
|
|
||||||
async broadcastTransaction(signed) {
|
|
||||||
if (mockNode.broadcastError) throw mockNode.broadcastError;
|
|
||||||
mockNode.broadcast.push(signed);
|
|
||||||
return { hash: "0x" + "ab".repeat(32) };
|
|
||||||
}
|
|
||||||
async getTransactionReceipt() {
|
|
||||||
return mockNode.receipt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
JsonRpcProvider: StubProvider,
|
|
||||||
Network: { from: () => ({}) },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
|
||||||
log: {
|
|
||||||
debugf: () => {},
|
|
||||||
infof: () => {},
|
|
||||||
warnf: () => {},
|
|
||||||
errorf: () => {},
|
|
||||||
},
|
|
||||||
// The explorer's token list, which refreshBalances() also fetches.
|
|
||||||
debugFetch: jest.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => mockExplorer.items,
|
|
||||||
})),
|
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
|
||||||
isDebug: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The confirmation screen's Etherscan label lookup is the only fetch() these
|
|
||||||
// screens make; it fails, as it does offline.
|
|
||||||
global.fetch = jest.fn(() => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
global.chrome = {
|
|
||||||
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
|
|
||||||
runtime: { sendMessage() {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
// A stub DOM: every id resolves to a recording element.
|
|
||||||
const elements = new Map();
|
|
||||||
|
|
||||||
function makeEl(id) {
|
|
||||||
const handlers = new Map();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
innerHTML: "",
|
|
||||||
value: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add() {},
|
|
||||||
remove() {},
|
|
||||||
toggle() {},
|
|
||||||
contains: () => false,
|
|
||||||
},
|
|
||||||
handlers,
|
|
||||||
children: [],
|
|
||||||
addEventListener(name, fn) {
|
|
||||||
handlers.set(name, fn);
|
|
||||||
},
|
|
||||||
appendChild(child) {
|
|
||||||
this.children.push(child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
querySelector: () => null,
|
|
||||||
remove() {},
|
|
||||||
focus() {},
|
|
||||||
// The success screen hides whole sections through .parentElement.
|
|
||||||
get parentElement() {
|
|
||||||
return global.document.getElementById(id + "-parent");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.document = {
|
|
||||||
getElementById(id) {
|
|
||||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
||||||
return elements.get(id);
|
|
||||||
},
|
|
||||||
createElement: (tag) => makeEl(tag),
|
|
||||||
body: { prepend() {}, appendChild() {} },
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
global.navigator.clipboard = { writeText() {} };
|
|
||||||
|
|
||||||
const {
|
|
||||||
Interface,
|
|
||||||
Transaction,
|
|
||||||
Wallet,
|
|
||||||
getAddress,
|
|
||||||
parseEther,
|
|
||||||
} = require("ethers");
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
|
||||||
const { encryptWithPassword } = require("../src/shared/vault");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const { clearPrices } = require("../src/shared/prices");
|
|
||||||
const send = require("../src/popup/views/send");
|
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
|
|
||||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
||||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
||||||
const PASSWORD = "correct horse battery staple";
|
|
||||||
|
|
||||||
const GWEI = 1000000000n;
|
|
||||||
// 1.234567890123456789 ETH.
|
|
||||||
const BALANCE_WEI = 1234567890123456789n;
|
|
||||||
|
|
||||||
// A token the bundled list does not know, with enough holders to be listed,
|
|
||||||
// at 6 decimal places.
|
|
||||||
const TOKEN = "0x" + "d".repeat(40);
|
|
||||||
|
|
||||||
const transferIface = new Interface([
|
|
||||||
"function transfer(address to, uint256 amount)",
|
|
||||||
]);
|
|
||||||
|
|
||||||
let encryptedSecret;
|
|
||||||
|
|
||||||
// The confirmation screen Review leads to, once shown.
|
|
||||||
let confirmed = null;
|
|
||||||
|
|
||||||
function el(id) {
|
|
||||||
return global.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(id) {
|
|
||||||
return el(id).textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// show() reads the balance and then estimates the fee without awaiting
|
|
||||||
// either; this lets both settle.
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
|
|
||||||
// base units of TOKEN, fetched and stored where the popup stores them. Send
|
|
||||||
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
|
|
||||||
async function openSend(token, tokenUnits = null) {
|
|
||||||
mockNode.balanceWei = BALANCE_WEI;
|
|
||||||
mockNode.tokenUnits = tokenUnits ?? 0n;
|
|
||||||
mockExplorer.items =
|
|
||||||
tokenUnits === null
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
value: String(tokenUnits),
|
|
||||||
token: {
|
|
||||||
type: "ERC-20",
|
|
||||||
address_hash: TOKEN,
|
|
||||||
symbol: "TOK",
|
|
||||||
name: "Token",
|
|
||||||
decimals: "6",
|
|
||||||
holders_count: "50000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "key",
|
|
||||||
name: "Wallet 1",
|
|
||||||
encryptedSecret,
|
|
||||||
addresses: [{ address: HOLDER }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
// Blockscout is asked only while the stored "Use Blockscout" is on. The
|
|
||||||
// screens save this page's copy of "Use Blockscout" over the stored one. So
|
|
||||||
// the page's copy is turned on here too.
|
|
||||||
state.useBlockscout = true;
|
|
||||||
await refreshBalances(
|
|
||||||
state.wallets,
|
|
||||||
"https://rpc.example.invalid",
|
|
||||||
"https://blockscout.example/api/v2",
|
|
||||||
state.trackedTokens,
|
|
||||||
"mainnet",
|
|
||||||
);
|
|
||||||
send.init({
|
|
||||||
showConfirmTx: (info) => (confirmed = info),
|
|
||||||
pageClosed: new AbortController().signal,
|
|
||||||
});
|
|
||||||
confirmTx.init({});
|
|
||||||
send.resetSendValidation();
|
|
||||||
state.currentView = "send";
|
|
||||||
state.selectedToken = token;
|
|
||||||
el("send-to").value = RECIPIENT;
|
|
||||||
el("send-amount").value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Press Review and show the confirmation screen with its fee estimate settled.
|
|
||||||
async function review() {
|
|
||||||
await el("btn-send-review").handlers.get("click")();
|
|
||||||
confirmTx.show(confirmed);
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signAndSend() {
|
|
||||||
el("confirm-tx-password").value = PASSWORD;
|
|
||||||
await el("btn-confirm-send").handlers.get("click")();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The one transaction the stub node was handed.
|
|
||||||
function broadcastTx() {
|
|
||||||
expect(mockNode.broadcast).toHaveLength(1);
|
|
||||||
return Transaction.from(mockNode.broadcast[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
elements.clear();
|
|
||||||
clearPrices();
|
|
||||||
confirmed = null;
|
|
||||||
state.selectedToken = null;
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.fraudContracts = [];
|
|
||||||
state.currentView = null;
|
|
||||||
mockNode.broadcast = [];
|
|
||||||
mockNode.broadcastError = null;
|
|
||||||
mockNode.receipt = null;
|
|
||||||
txStatus.init({
|
|
||||||
doRefreshAndRender() {},
|
|
||||||
pageClosed: new AbortController().signal,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
txStatus.endWait();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the wallet's own send shows its amount to four decimal places", () => {
|
|
||||||
test("for a short amount", async () => {
|
|
||||||
await openSend("ETH");
|
|
||||||
el("send-amount").value = "0.5";
|
|
||||||
await review();
|
|
||||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
|
||||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send accepts both. The approval screen writes the same value `0.5000`.
|
|
||||||
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
|
|
||||||
await openSend("ETH");
|
|
||||||
el("send-amount").value = typed;
|
|
||||||
await review();
|
|
||||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
|
||||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("for a token amount typed as .5", async () => {
|
|
||||||
// 100 TOK.
|
|
||||||
await openSend(TOKEN, 100000000n);
|
|
||||||
el("send-amount").value = ".5";
|
|
||||||
await review();
|
|
||||||
expect(text("confirm-amount")).toBe("0.5000 TOK");
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
|
|
||||||
const [, amount] = transferIface.decodeFunctionData(
|
|
||||||
"transfer",
|
|
||||||
broadcastTx().data,
|
|
||||||
);
|
|
||||||
expect(amount).toBe(500000n);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("for a Max amount with many decimals", async () => {
|
|
||||||
await openSend("ETH");
|
|
||||||
await el("btn-send-max").handlers.get("click")();
|
|
||||||
// The balance minus 21000 gas at 20 gwei.
|
|
||||||
expect(el("send-amount").value).toBe("1.234147890123456789");
|
|
||||||
await review();
|
|
||||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
|
|
||||||
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cut to four decimals it would read 0.0000, so it is shown to its first
|
|
||||||
// significant digit instead, and signed in full.
|
|
||||||
test("for a tiny nonzero amount", async () => {
|
|
||||||
await openSend("ETH");
|
|
||||||
el("send-amount").value = "0.000012345";
|
|
||||||
await review();
|
|
||||||
expect(text("confirm-amount")).toBe("0.00001 ETH");
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
|
|
||||||
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("for a token amount", async () => {
|
|
||||||
// 100 TOK.
|
|
||||||
await openSend(TOKEN, 100000000n);
|
|
||||||
el("send-amount").value = "12.345678";
|
|
||||||
await review();
|
|
||||||
expect(text("confirm-amount")).toBe("12.3456 TOK");
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
|
|
||||||
const tx = broadcastTx();
|
|
||||||
expect(tx.to).toBe(getAddress(TOKEN));
|
|
||||||
const [to, amount] = transferIface.decodeFunctionData(
|
|
||||||
"transfer",
|
|
||||||
tx.data,
|
|
||||||
);
|
|
||||||
expect(to).toBe(RECIPIENT);
|
|
||||||
expect(amount).toBe(12345678n);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on the success screen", async () => {
|
|
||||||
await openSend("ETH");
|
|
||||||
el("send-amount").value = "0.5";
|
|
||||||
await review();
|
|
||||||
await signAndSend();
|
|
||||||
// A popup opened later resumes the wait, and the receipt is there.
|
|
||||||
mockNode.receipt = { blockNumber: 21000000 };
|
|
||||||
txStatus.restoreWait();
|
|
||||||
await settle();
|
|
||||||
expect(state.currentView).toBe("success-tx");
|
|
||||||
expect(text("success-tx-summary")).toBe("0.5000 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on the error screen", async () => {
|
|
||||||
await openSend("ETH");
|
|
||||||
el("send-amount").value = "0.5";
|
|
||||||
await review();
|
|
||||||
mockNode.broadcastError = new Error("the node refused it");
|
|
||||||
await signAndSend();
|
|
||||||
expect(state.currentView).toBe("error-tx");
|
|
||||||
expect(text("error-tx-summary")).toBe("0.5000 ETH");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,115 +11,20 @@
|
|||||||
//
|
//
|
||||||
// The check is driven here with a fake connected signer rather than a real
|
// The check is driven here with a fake connected signer rather than a real
|
||||||
// one: populateTransaction() returns the fees the node would have produced,
|
// one: populateTransaction() returns the fees the node would have produced,
|
||||||
// and sendTransaction() records whether the send actually happened.
|
// and sendTransaction() records whether the send actually happened. The real
|
||||||
//
|
// DOM path around it — reading the fee error into the reserved errors box — is
|
||||||
// The last test drives the real confirmation screen instead, with only the
|
// covered by the Chrome e2e suite.
|
||||||
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
|
|
||||||
// is refused in the same box as a fee over the bound
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
|
|
||||||
// nothing on the screen is checked by the Chrome e2e suite.
|
|
||||||
|
|
||||||
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
|
globalThis.chrome = {
|
||||||
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
// it was handed.
|
|
||||||
const mockNode = {
|
|
||||||
gasLimit: 150000000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
broadcast: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock("ethers", () => {
|
|
||||||
const actual = jest.requireActual("ethers");
|
|
||||||
class StubProvider {
|
|
||||||
constructor(url, network) {
|
|
||||||
this.network = network;
|
|
||||||
}
|
|
||||||
async getNetwork() {
|
|
||||||
return this.network;
|
|
||||||
}
|
|
||||||
async getBalance() {
|
|
||||||
return actual.parseEther("5");
|
|
||||||
}
|
|
||||||
async getCode() {
|
|
||||||
return "0x";
|
|
||||||
}
|
|
||||||
async getTransactionCount() {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
async getFeeData() {
|
|
||||||
return {
|
|
||||||
maxFeePerGas: mockNode.maxFeePerGas,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
gasPrice: 1000000000n,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async estimateGas() {
|
|
||||||
return mockNode.gasLimit;
|
|
||||||
}
|
|
||||||
async broadcastTransaction(signed) {
|
|
||||||
mockNode.broadcast.push(signed);
|
|
||||||
return { hash: "0x" + "ab".repeat(32) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...actual, JsonRpcProvider: StubProvider };
|
|
||||||
});
|
|
||||||
|
|
||||||
// The screens a send that was not refused goes on to.
|
|
||||||
jest.mock("../src/popup/views/txStatus", () => ({
|
|
||||||
showWait: jest.fn(),
|
|
||||||
showError: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
|
||||||
|
|
||||||
global.fetch = jest.fn(() => {
|
global.fetch = jest.fn(() => {
|
||||||
throw new Error("tests must not perform network requests");
|
throw new Error("tests must not perform network requests");
|
||||||
});
|
});
|
||||||
|
|
||||||
// A stub DOM: every id resolves to a recording element.
|
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
|
||||||
const elements = new Map();
|
|
||||||
|
|
||||||
function makeEl(id) {
|
|
||||||
const handlers = new Map();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
innerHTML: "",
|
|
||||||
value: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add() {},
|
|
||||||
remove() {},
|
|
||||||
toggle() {},
|
|
||||||
contains: () => false,
|
|
||||||
},
|
|
||||||
handlers,
|
|
||||||
addEventListener(name, fn) {
|
|
||||||
handlers.set(name, fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
remove() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.document = {
|
|
||||||
getElementById(id) {
|
|
||||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
||||||
return elements.get(id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { Wallet } = require("ethers");
|
|
||||||
const { encryptWithPassword } = require("../src/shared/vault");
|
|
||||||
const { state, loadState } = require("../src/shared/state");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
|
||||||
const { populateVerifyAndSend } = confirmTx;
|
|
||||||
const {
|
const {
|
||||||
MAX_GAS_LIMIT,
|
|
||||||
MAX_FEE_PER_GAS,
|
MAX_FEE_PER_GAS,
|
||||||
MAX_TOTAL_FEE,
|
MAX_TOTAL_FEE,
|
||||||
} = require("../src/shared/approvalVerify");
|
} = require("../src/shared/approvalVerify");
|
||||||
@@ -193,69 +98,3 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
|
|||||||
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
|
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Sign & Send on the confirmation screen", () => {
|
|
||||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
||||||
const PASSWORD = "correct horse battery staple";
|
|
||||||
let encryptedSecret;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await loadState();
|
|
||||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
|
||||||
});
|
|
||||||
|
|
||||||
function el(id) {
|
|
||||||
return global.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shown(id) {
|
|
||||||
return el(id).style.visibility === "visible";
|
|
||||||
}
|
|
||||||
|
|
||||||
// show() reads the balance and estimates the fee without awaiting
|
|
||||||
// either; this lets both finish.
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
|
|
||||||
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
|
|
||||||
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
|
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const from = new Wallet(PRIVATE_KEY).address;
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "key",
|
|
||||||
name: "Wallet 1",
|
|
||||||
encryptedSecret,
|
|
||||||
addresses: [{ address: from }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
confirmTx.init({});
|
|
||||||
confirmTx.show({
|
|
||||||
from,
|
|
||||||
to: RECIPIENT,
|
|
||||||
ensName: null,
|
|
||||||
amount: "1",
|
|
||||||
token: "ETH",
|
|
||||||
tokenSymbol: null,
|
|
||||||
tokenDecimals: null,
|
|
||||||
max: false,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
||||||
expect(shown("confirm-fee-bound-error")).toBe(false);
|
|
||||||
|
|
||||||
el("confirm-tx-password").value = PASSWORD;
|
|
||||||
await el("btn-confirm-send").handlers.get("click")();
|
|
||||||
|
|
||||||
expect(shown("confirm-fee-bound-error")).toBe(true);
|
|
||||||
expect(mockNode.broadcast).toEqual([]);
|
|
||||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
|
||||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
// A late answer for a confirmation screen the user has left changes nothing on
|
|
||||||
// the screen shown since (https://git.eeqj.de/sneak/AutistMask/issues/575).
|
|
||||||
//
|
|
||||||
// The confirmation screen asks the node and the explorer about its transaction
|
|
||||||
// without waiting for the answers: the recipient checks, which can show three
|
|
||||||
// warning boxes, the balance read and the fee estimate. Back, a new recipient
|
|
||||||
// and Review again show the screen for another transaction while those
|
|
||||||
// questions may still be open. The recipient checks showed their boxes
|
|
||||||
// whenever they answered, so a box about the first recipient could appear on
|
|
||||||
// the second recipient's screen.
|
|
||||||
//
|
|
||||||
// Driven through the real confirmation screen, with only the node, the
|
|
||||||
// explorer and the DOM stubbed. Each test holds back the first screen's answer
|
|
||||||
// to one question, and gives it once the screen it tests is shown.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// The From address of every transaction here. The jest.mock() factory below
|
|
||||||
// may only name outside variables whose names start with "mock".
|
|
||||||
const mockFrom = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
jest.mock("ethers", () => {
|
|
||||||
const actual = jest.requireActual("ethers");
|
|
||||||
// Every answer is a mock, so a test can hold one back. Every address has
|
|
||||||
// sent one transaction and has no code, and only the From address holds
|
|
||||||
// anything: 5 ETH.
|
|
||||||
class StubProvider {}
|
|
||||||
Object.assign(StubProvider.prototype, {
|
|
||||||
getBalance: jest.fn(async (address) =>
|
|
||||||
address === mockFrom ? actual.parseEther("5") : 0n,
|
|
||||||
),
|
|
||||||
getCode: jest.fn(async () => "0x"),
|
|
||||||
getTransactionCount: jest.fn(async () => 1),
|
|
||||||
// 10 gwei expected, 20 gwei reserved per gas.
|
|
||||||
getFeeData: jest.fn(async () => ({
|
|
||||||
maxFeePerGas: 20000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
gasPrice: 10000000000n,
|
|
||||||
})),
|
|
||||||
estimateGas: jest.fn(async () => 21000n),
|
|
||||||
});
|
|
||||||
return { ...actual, JsonRpcProvider: StubProvider };
|
|
||||||
});
|
|
||||||
|
|
||||||
// An explorer address page whose title is `title`.
|
|
||||||
function explorerPage(title) {
|
|
||||||
return { ok: true, text: async () => "<title>" + title + "</title>" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The explorer labels no address.
|
|
||||||
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
|
|
||||||
|
|
||||||
// The Etherscan label check is made, so its box can be shown.
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
global.chrome = {
|
|
||||||
storage: makeStorageStub({
|
|
||||||
autistmask: { checkRecipientsOnEtherscan: true },
|
|
||||||
}),
|
|
||||||
runtime: { sendMessage() {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
// A stub DOM: every id resolves to a recording element.
|
|
||||||
const elements = new Map();
|
|
||||||
|
|
||||||
function makeEl(id) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
innerHTML: "",
|
|
||||||
value: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add() {},
|
|
||||||
remove() {},
|
|
||||||
toggle() {},
|
|
||||||
contains: () => false,
|
|
||||||
},
|
|
||||||
addEventListener() {},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
remove() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.document = {
|
|
||||||
getElementById(id) {
|
|
||||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
||||||
return elements.get(id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { JsonRpcProvider, parseEther } = require("ethers");
|
|
||||||
const { loadState } = require("../src/shared/state");
|
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
|
||||||
|
|
||||||
const node = JsonRpcProvider.prototype;
|
|
||||||
const FIRST = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
||||||
const SECOND = "0xE2E0000000000000000000000000000000000E2e";
|
|
||||||
|
|
||||||
function el(id) {
|
|
||||||
return global.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shown(id) {
|
|
||||||
return el(id).style.visibility === "visible";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the confirmation screen for 1 ETH to `to` on mainnet, as Review on the
|
|
||||||
// Send screen does.
|
|
||||||
function review(to) {
|
|
||||||
confirmTx.show({
|
|
||||||
from: mockFrom,
|
|
||||||
to,
|
|
||||||
ensName: null,
|
|
||||||
amount: "1",
|
|
||||||
token: "ETH",
|
|
||||||
tokenSymbol: null,
|
|
||||||
tokenDecimals: null,
|
|
||||||
max: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// show() does not wait for its questions; this lets every answer not held
|
|
||||||
// back arrive.
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hold back the next answer `question` gives. Returns the function that gives
|
|
||||||
// it.
|
|
||||||
function holdNextAnswer(question) {
|
|
||||||
let give;
|
|
||||||
question.mockImplementationOnce(() => new Promise((r) => (give = r)));
|
|
||||||
return (answer) => give(answer);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await loadState();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
elements.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The three boxes the recipient checks show, each with the question whose
|
|
||||||
// answer shows it for FIRST, and that answer. FIRST holds no ETH, so the
|
|
||||||
// answer that it has sent no transaction is what shows the box saying so.
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
box: "confirm-contract-warning",
|
|
||||||
question: node.getCode,
|
|
||||||
answer: "0x6080604052",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
box: "confirm-recipient-warning",
|
|
||||||
question: node.getTransactionCount,
|
|
||||||
answer: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
box: "confirm-etherscan-warning",
|
|
||||||
question: global.fetch,
|
|
||||||
answer: explorerPage(
|
|
||||||
"Fake_Phishing1 | Address: " + FIRST + " | Etherscan",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
])("the $box box", ({ box, question, answer }) => {
|
|
||||||
test("is shown when the answer about the recipient arrives while its screen is shown", async () => {
|
|
||||||
const give = holdNextAnswer(question);
|
|
||||||
review(FIRST);
|
|
||||||
await settle();
|
|
||||||
expect(shown(box)).toBe(false);
|
|
||||||
|
|
||||||
give(answer);
|
|
||||||
await settle();
|
|
||||||
expect(shown(box)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is not shown on the next recipient's screen when the answer about the first arrives after that screen is shown", async () => {
|
|
||||||
const give = holdNextAnswer(question);
|
|
||||||
review(FIRST);
|
|
||||||
await settle();
|
|
||||||
review(SECOND);
|
|
||||||
await settle();
|
|
||||||
expect(shown(box)).toBe(false);
|
|
||||||
|
|
||||||
give(answer);
|
|
||||||
await settle();
|
|
||||||
expect(shown(box)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a balance read answered after the next screen is shown leaves that screen's balance and balance check as they are", async () => {
|
|
||||||
const give = holdNextAnswer(node.getBalance);
|
|
||||||
review(FIRST);
|
|
||||||
await settle();
|
|
||||||
review(SECOND);
|
|
||||||
await settle();
|
|
||||||
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
|
||||||
|
|
||||||
give(parseEther("0.5"));
|
|
||||||
await settle();
|
|
||||||
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
|
||||||
expect(shown("confirm-balance-error")).toBe(false);
|
|
||||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a fee estimate answered after the next screen is shown leaves that screen's fee as it is", async () => {
|
|
||||||
const give = holdNextAnswer(node.getFeeData);
|
|
||||||
review(FIRST);
|
|
||||||
await settle();
|
|
||||||
review(SECOND);
|
|
||||||
await settle();
|
|
||||||
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
|
||||||
|
|
||||||
// 100 times the fee the second screen was quoted.
|
|
||||||
give({
|
|
||||||
maxFeePerGas: 2000000000000n,
|
|
||||||
maxPriorityFeePerGas: 100000000000n,
|
|
||||||
gasPrice: 1000000000000n,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
|
||||||
expect(el("confirm-fee-reserve").textContent).toBe(
|
|
||||||
"up to 0.0004 ETH reserved",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,783 +0,0 @@
|
|||||||
// A transfer is sent only on the network its confirmation screen was drawn for
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/524).
|
|
||||||
//
|
|
||||||
// Settings is reachable from the confirmation screen, and Back from it only
|
|
||||||
// unhides the screen, still labelled for the network it was drawn for. Sign &
|
|
||||||
// Send used to take the network at the moment of the click, so 1 SepoliaETH
|
|
||||||
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
|
|
||||||
// Settings in between.
|
|
||||||
//
|
|
||||||
// The same holds when the popup was closed after the switch and reopened, and
|
|
||||||
// Back drew the screen again from the transaction it saved
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for
|
|
||||||
// the new network, and Sign & Send sent the reviewed transfer there. Drawn
|
|
||||||
// again, the screen describes the transaction entirely on the network it was
|
|
||||||
// reviewed for, because choosing that network again in Settings and pressing
|
|
||||||
// Back only unhides it, and Sign & Send then sends from it.
|
|
||||||
//
|
|
||||||
// Its balance line and balance check are that network's balance, read from
|
|
||||||
// that network's node each time the screen is drawn
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/558), not the balance Send
|
|
||||||
// handed over. The fee estimate after the read stays on that network when
|
|
||||||
// the network is switched while the read runs.
|
|
||||||
//
|
|
||||||
// Driven through the real confirmation screen, signer and chain switch, with
|
|
||||||
// only the node, fetch and the DOM stubbed. The stub node is on the network
|
|
||||||
// its provider was built for, so a broadcast records the network it went to,
|
|
||||||
// and a balance read answers with that network's balance.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// The questions the stub node was asked for the balance, the fee and the
|
|
||||||
// recipient checks, by method, network name and RPC URL, in order. The
|
|
||||||
// balances it reports on each network, by network name: ETH in wei and the
|
|
||||||
// token in its base units. The signed transactions it was handed, with its
|
|
||||||
// network's chain id and RPC URL. And whether it quotes fees as a network
|
|
||||||
// with no type-2 pricing does.
|
|
||||||
const mockNode = {
|
|
||||||
asked: [],
|
|
||||||
balanceWei: {},
|
|
||||||
tokenUnits: {},
|
|
||||||
broadcast: [],
|
|
||||||
legacyFees: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock("ethers", () => {
|
|
||||||
const actual = jest.requireActual("ethers");
|
|
||||||
// The selector of balanceOf(address).
|
|
||||||
const BALANCE_OF = "0x70a08231";
|
|
||||||
class StubProvider {
|
|
||||||
constructor(url, network) {
|
|
||||||
this.url = url;
|
|
||||||
this.network = network;
|
|
||||||
}
|
|
||||||
record(method) {
|
|
||||||
mockNode.asked.push({
|
|
||||||
method,
|
|
||||||
network: this.network.name,
|
|
||||||
url: this.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async getNetwork() {
|
|
||||||
return this.network;
|
|
||||||
}
|
|
||||||
async getBalance() {
|
|
||||||
this.record("getBalance");
|
|
||||||
return mockNode.balanceWei[this.network.name];
|
|
||||||
}
|
|
||||||
// balanceOf() is the one contract call the screen makes before Sign
|
|
||||||
// & Send.
|
|
||||||
async call(tx) {
|
|
||||||
if (!tx.data.startsWith(BALANCE_OF)) {
|
|
||||||
throw new Error("the stub node answers only balanceOf()");
|
|
||||||
}
|
|
||||||
this.record("balanceOf");
|
|
||||||
return actual.AbiCoder.defaultAbiCoder().encode(
|
|
||||||
["uint256"],
|
|
||||||
[mockNode.tokenUnits[this.network.name]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
async getFeeData() {
|
|
||||||
this.record("getFeeData");
|
|
||||||
if (mockNode.legacyFees) {
|
|
||||||
return {
|
|
||||||
maxFeePerGas: null,
|
|
||||||
maxPriorityFeePerGas: null,
|
|
||||||
gasPrice: 10000000000n,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
maxFeePerGas: 20000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
gasPrice: 10000000000n,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async estimateGas() {
|
|
||||||
return 21000n;
|
|
||||||
}
|
|
||||||
async getCode() {
|
|
||||||
this.record("getCode");
|
|
||||||
return "0x";
|
|
||||||
}
|
|
||||||
async getTransactionCount() {
|
|
||||||
this.record("getTransactionCount");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
async lookupAddress() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
async broadcastTransaction(signed) {
|
|
||||||
mockNode.broadcast.push({
|
|
||||||
chainId: this.network.chainId,
|
|
||||||
url: this.url,
|
|
||||||
signed,
|
|
||||||
});
|
|
||||||
return { hash: "0x" + "ab".repeat(32) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...actual, JsonRpcProvider: StubProvider };
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
|
||||||
log: {
|
|
||||||
debugf: () => {},
|
|
||||||
infof: () => {},
|
|
||||||
warnf: () => {},
|
|
||||||
errorf: () => {},
|
|
||||||
},
|
|
||||||
debugFetch: jest.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({ items: [] }),
|
|
||||||
})),
|
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
|
||||||
isDebug: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The wait screen polls for a receipt; these tests stop at the broadcast.
|
|
||||||
jest.mock("../src/popup/views/txStatus", () => ({
|
|
||||||
showWait: jest.fn(),
|
|
||||||
showError: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The Etherscan label check is its only caller here: the explorer pages it
|
|
||||||
// asks for are recorded, and nothing is fetched. The stored profile has the
|
|
||||||
// check turned on.
|
|
||||||
global.fetch = jest.fn(() => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
global.chrome = {
|
|
||||||
storage: makeStorageStub({
|
|
||||||
autistmask: { checkRecipientsOnEtherscan: true },
|
|
||||||
}),
|
|
||||||
runtime: { sendMessage() {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
// A stub DOM: every id resolves to a recording element.
|
|
||||||
const elements = new Map();
|
|
||||||
|
|
||||||
function makeEl(id) {
|
|
||||||
const handlers = new Map();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
innerHTML: "",
|
|
||||||
value: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add() {},
|
|
||||||
remove() {},
|
|
||||||
toggle() {},
|
|
||||||
contains: () => false,
|
|
||||||
},
|
|
||||||
handlers,
|
|
||||||
addEventListener(name, fn) {
|
|
||||||
handlers.set(name, fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
querySelector: () => null,
|
|
||||||
remove() {},
|
|
||||||
focus() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.document = {
|
|
||||||
getElementById(id) {
|
|
||||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
||||||
return elements.get(id);
|
|
||||||
},
|
|
||||||
createElement: (tag) => makeEl(tag),
|
|
||||||
body: { prepend() {}, appendChild() {} },
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
global.navigator.clipboard = { writeText() {} };
|
|
||||||
|
|
||||||
const {
|
|
||||||
BaseWallet,
|
|
||||||
JsonRpcProvider,
|
|
||||||
Transaction,
|
|
||||||
Wallet,
|
|
||||||
parseEther,
|
|
||||||
} = require("ethers");
|
|
||||||
const { encryptWithPassword } = require("../src/shared/vault");
|
|
||||||
const { state, loadState, saveState } = require("../src/shared/state");
|
|
||||||
const { onChainSwitch } = require("../src/shared/chainSwitch");
|
|
||||||
const { NETWORKS } = require("../src/shared/networks");
|
|
||||||
const { prices } = require("../src/shared/prices");
|
|
||||||
const {
|
|
||||||
goBack,
|
|
||||||
pushCurrentView,
|
|
||||||
setBackRenderer,
|
|
||||||
showView,
|
|
||||||
} = require("../src/popup/views/helpers");
|
|
||||||
const {
|
|
||||||
makeBackRenderer,
|
|
||||||
resetRenderedViews,
|
|
||||||
} = require("../src/popup/viewRouter");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
|
||||||
|
|
||||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
||||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
||||||
const TOKEN = "0x7070707070707070707070707070707070707070";
|
|
||||||
const PASSWORD = "correct horse battery staple";
|
|
||||||
|
|
||||||
function el(id) {
|
|
||||||
return global.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let encryptedSecret;
|
|
||||||
let signTransaction;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await loadState();
|
|
||||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
elements.clear();
|
|
||||||
mockNode.asked = [];
|
|
||||||
mockNode.balanceWei = {
|
|
||||||
mainnet: parseEther("5"),
|
|
||||||
sepolia: parseEther("5"),
|
|
||||||
};
|
|
||||||
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
|
|
||||||
mockNode.broadcast = [];
|
|
||||||
mockNode.legacyFees = false;
|
|
||||||
global.fetch.mockClear();
|
|
||||||
txStatus.showWait.mockClear();
|
|
||||||
txStatus.showError.mockClear();
|
|
||||||
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
signTransaction.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show the confirmation screen for 1 of the native token on `networkId`, or
|
|
||||||
// for the transfer `changes` makes of it, as Review on the Send screen does,
|
|
||||||
// with its balance read and its fee estimate settled.
|
|
||||||
async function review(networkId, changes = {}) {
|
|
||||||
await onChainSwitch(networkId);
|
|
||||||
await reviewOnActiveNetwork(changes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// review() on the network already active, without switching to it again.
|
|
||||||
async function reviewOnActiveNetwork(changes = {}) {
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "key",
|
|
||||||
name: "Wallet 1",
|
|
||||||
encryptedSecret,
|
|
||||||
addresses: [{ address: HOLDER }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
confirmTx.init({});
|
|
||||||
confirmTx.show({
|
|
||||||
from: HOLDER,
|
|
||||||
to: RECIPIENT,
|
|
||||||
ensName: null,
|
|
||||||
amount: "1",
|
|
||||||
token: "ETH",
|
|
||||||
tokenSymbol: null,
|
|
||||||
tokenDecimals: null,
|
|
||||||
max: false,
|
|
||||||
...changes,
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user's own node for a network, set in Settings.
|
|
||||||
function ownNode(networkId) {
|
|
||||||
return "https://" + networkId + ".node.example.invalid";
|
|
||||||
}
|
|
||||||
|
|
||||||
// After review() on `reviewedOn`: the user's own node set for it, the Settings
|
|
||||||
// gear, `switchedTo` chosen there, the popup closed and reopened onto
|
|
||||||
// Settings, and Back, which draws the confirmation screen again from the
|
|
||||||
// transaction it saved. The reopened page holds an ETH price of $2,000 and a
|
|
||||||
// USDC price of $1 whichever network is active, so each USD value shows which
|
|
||||||
// network it was priced for.
|
|
||||||
async function switchReopenAndGoBack(reviewedOn, switchedTo) {
|
|
||||||
state.rpcUrl = ownNode(reviewedOn);
|
|
||||||
pushCurrentView();
|
|
||||||
showView("settings");
|
|
||||||
await onChainSwitch(switchedTo);
|
|
||||||
|
|
||||||
await loadState();
|
|
||||||
elements.clear();
|
|
||||||
resetRenderedViews();
|
|
||||||
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
|
||||||
confirmTx.init({});
|
|
||||||
prices.ETH = 2000;
|
|
||||||
prices.USDC = 1;
|
|
||||||
mockNode.asked = [];
|
|
||||||
global.fetch.mockClear();
|
|
||||||
goBack();
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The USD value `text` as the screen appends it for `networkId`: nothing on a
|
|
||||||
// testnet, which has no USD values.
|
|
||||||
function usdOn(networkId, text) {
|
|
||||||
return NETWORKS[networkId].isTestnet ? "" : " (" + text + ")";
|
|
||||||
}
|
|
||||||
|
|
||||||
// The screen for the transfer review() shows by default, described on
|
|
||||||
// `networkId`: in its native currency, priced by it, linking to its explorer.
|
|
||||||
function expectNativeTransferOn(networkId) {
|
|
||||||
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[networkId];
|
|
||||||
expect(el("confirm-type").textContent).toBe(
|
|
||||||
"Native " + symbol + " transfer",
|
|
||||||
);
|
|
||||||
expect(el("confirm-amount").textContent).toBe(
|
|
||||||
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
|
|
||||||
);
|
|
||||||
expect(el("confirm-balance").textContent).toBe(
|
|
||||||
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
|
||||||
);
|
|
||||||
expect(el("confirm-fee-amount").textContent).toBe(
|
|
||||||
"~0.0002 " + symbol + usdOn(networkId, "$0.42"),
|
|
||||||
);
|
|
||||||
expect(el("confirm-fee-reserve").textContent).toBe(
|
|
||||||
"up to 0.0004 " + symbol + " reserved",
|
|
||||||
);
|
|
||||||
expect(el("confirm-gas-error").textContent).toBe(
|
|
||||||
"You do not have enough " +
|
|
||||||
symbol +
|
|
||||||
" to pay the network fee for this transfer. Please add " +
|
|
||||||
symbol +
|
|
||||||
" to this address and try again.",
|
|
||||||
);
|
|
||||||
expect(el("confirm-recipient-warning").textContent).toContain(
|
|
||||||
" holds no " + symbol + ". ",
|
|
||||||
);
|
|
||||||
expect(el("confirm-contract-warning").textContent).toContain(
|
|
||||||
" Sending " + symbol + " or tokens ",
|
|
||||||
);
|
|
||||||
expect(el("confirm-from").innerHTML).toContain(
|
|
||||||
`href="${explorerUrl}/address/${HOLDER}"`,
|
|
||||||
);
|
|
||||||
expect(el("confirm-to").innerHTML).toContain(
|
|
||||||
`href="${explorerUrl}/address/${RECIPIENT}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// show() reads the balance and then estimates the fee, and checks the
|
|
||||||
// recipient, without awaiting any of them; this lets all of them finish.
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function errors() {
|
|
||||||
return el("confirm-errors").innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shown(id) {
|
|
||||||
return el(id).style.visibility === "visible";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendDisabled() {
|
|
||||||
return el("btn-confirm-send").disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signAndSend() {
|
|
||||||
el("confirm-tx-password").value = PASSWORD;
|
|
||||||
await el("btn-confirm-send").handlers.get("click")();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => {
|
|
||||||
await review("sepolia");
|
|
||||||
expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer");
|
|
||||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
||||||
await signAndSend();
|
|
||||||
expect(mockNode.broadcast).toHaveLength(1);
|
|
||||||
expect(mockNode.broadcast[0].chainId).toBe(11155111n);
|
|
||||||
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
|
|
||||||
11155111n,
|
|
||||||
);
|
|
||||||
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Saving an RPC endpoint in Settings (src/popup/views/settings.js) sets only
|
|
||||||
// state.rpcUrl: the endpoint remembered for the active network keeps its old
|
|
||||||
// value until the next switch away from it.
|
|
||||||
test("a transfer reviewed after the active network's RPC endpoint was changed in Settings asks the new endpoint", async () => {
|
|
||||||
const newNode = "https://new.node.example.invalid";
|
|
||||||
await onChainSwitch("sepolia");
|
|
||||||
state.rpcUrl = newNode;
|
|
||||||
await saveState();
|
|
||||||
|
|
||||||
await reviewOnActiveNetwork();
|
|
||||||
|
|
||||||
expect(mockNode.asked).toEqual([
|
|
||||||
{ method: "getBalance", network: "sepolia", url: newNode },
|
|
||||||
{ method: "getCode", network: "sepolia", url: newNode },
|
|
||||||
{ method: "getTransactionCount", network: "sepolia", url: newNode },
|
|
||||||
{ method: "getFeeData", network: "sepolia", url: newNode },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["sepolia", "mainnet", "SepoliaETH"],
|
|
||||||
["mainnet", "sepolia", "ETH"],
|
|
||||||
])(
|
|
||||||
"a transfer reviewed on %s, with %s chosen in Settings before Sign & Send",
|
|
||||||
(reviewedOn, switchedTo, symbol) => {
|
|
||||||
test("is neither signed nor sent, and the screen says why", async () => {
|
|
||||||
await review(reviewedOn);
|
|
||||||
expect(el("confirm-type").textContent).toBe(
|
|
||||||
"Native " + symbol + " transfer",
|
|
||||||
);
|
|
||||||
// Settings' network select, then Back, which only unhides the
|
|
||||||
// screen as it was drawn.
|
|
||||||
await onChainSwitch(switchedTo);
|
|
||||||
await signAndSend();
|
|
||||||
|
|
||||||
expect(signTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(mockNode.broadcast).toEqual([]);
|
|
||||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
|
||||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
|
||||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
|
||||||
expect(shown("confirm-fee-bound-error")).toBe(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["sepolia", "mainnet"],
|
|
||||||
["mainnet", "sepolia"],
|
|
||||||
])(
|
|
||||||
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
|
|
||||||
(reviewedOn, switchedTo) => {
|
|
||||||
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn];
|
|
||||||
|
|
||||||
test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
|
|
||||||
await review(reviewedOn);
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(state.currentView).toBe("confirm-tx");
|
|
||||||
expectNativeTransferOn(reviewedOn);
|
|
||||||
expect(mockNode.asked).toEqual([
|
|
||||||
{
|
|
||||||
method: "getBalance",
|
|
||||||
network: reviewedOn,
|
|
||||||
url: ownNode(reviewedOn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "getCode",
|
|
||||||
network: reviewedOn,
|
|
||||||
url: ownNode(reviewedOn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "getTransactionCount",
|
|
||||||
network: reviewedOn,
|
|
||||||
url: ownNode(reviewedOn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "getFeeData",
|
|
||||||
network: reviewedOn,
|
|
||||||
url: ownNode(reviewedOn),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
|
|
||||||
explorerUrl + "/address/" + RECIPIENT,
|
|
||||||
]);
|
|
||||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
|
|
||||||
expect(signTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(mockNode.broadcast).toEqual([]);
|
|
||||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
|
||||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
|
||||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
|
|
||||||
await review(reviewedOn);
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
// The Settings gear, the reviewed network chosen, and Back, which
|
|
||||||
// only unhides the screen as it was drawn.
|
|
||||||
pushCurrentView();
|
|
||||||
showView("settings");
|
|
||||||
await onChainSwitch(reviewedOn);
|
|
||||||
goBack();
|
|
||||||
|
|
||||||
expect(state.currentView).toBe("confirm-tx");
|
|
||||||
expectNativeTransferOn(reviewedOn);
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
|
|
||||||
const chainId = BigInt(NETWORKS[reviewedOn].chainId);
|
|
||||||
expect(mockNode.broadcast).toHaveLength(1);
|
|
||||||
expect(mockNode.broadcast[0].chainId).toBe(chainId);
|
|
||||||
expect(mockNode.broadcast[0].url).toBe(ownNode(reviewedOn));
|
|
||||||
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
|
|
||||||
chainId,
|
|
||||||
);
|
|
||||||
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("for more than the balance, says so in that network's native currency", async () => {
|
|
||||||
await review(reviewedOn, { amount: "10" });
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(shown("confirm-balance-error")).toBe(true);
|
|
||||||
expect(el("confirm-balance-error").textContent).toBe(
|
|
||||||
"Your " +
|
|
||||||
symbol +
|
|
||||||
" balance does not cover this amount. Please go back and" +
|
|
||||||
" send a smaller amount.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("states and checks that network's balance as it is then, which no longer covers the send", async () => {
|
|
||||||
await review(reviewedOn);
|
|
||||||
expect(sendDisabled()).toBe(false);
|
|
||||||
mockNode.balanceWei = {
|
|
||||||
[reviewedOn]: parseEther("0.5"),
|
|
||||||
[switchedTo]: parseEther("5"),
|
|
||||||
};
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(el("confirm-balance").textContent).toBe(
|
|
||||||
"0.5000 " + symbol + usdOn(reviewedOn, "$1,000.00"),
|
|
||||||
);
|
|
||||||
expect(shown("confirm-balance-error")).toBe(true);
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("states and checks that network's balance as it is then, which now covers the send, and refuses to send it", async () => {
|
|
||||||
mockNode.balanceWei[reviewedOn] = parseEther("0.5");
|
|
||||||
await review(reviewedOn);
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
mockNode.balanceWei = {
|
|
||||||
[reviewedOn]: parseEther("5"),
|
|
||||||
[switchedTo]: parseEther("0.5"),
|
|
||||||
};
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(el("confirm-balance").textContent).toBe(
|
|
||||||
"5.0000 " + symbol + usdOn(reviewedOn, "$10,000.00"),
|
|
||||||
);
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
expect(shown("confirm-balance-error")).toBe(false);
|
|
||||||
expect(shown("confirm-amount-fee-error")).toBe(false);
|
|
||||||
expect(sendDisabled()).toBe(false);
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
|
|
||||||
expect(signTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(mockNode.broadcast).toEqual([]);
|
|
||||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenSend = {
|
|
||||||
token: TOKEN,
|
|
||||||
tokenSymbol: "TOK",
|
|
||||||
tokenDecimals: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("of a token, states and checks that network's token balance as it is then, which no longer covers the send", async () => {
|
|
||||||
mockNode.tokenUnits[reviewedOn] = 10000000n;
|
|
||||||
await review(reviewedOn, tokenSend);
|
|
||||||
expect(sendDisabled()).toBe(false);
|
|
||||||
mockNode.tokenUnits = {
|
|
||||||
[reviewedOn]: 500000n,
|
|
||||||
[switchedTo]: 10000000n,
|
|
||||||
};
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
|
|
||||||
expect(shown("confirm-balance-error")).toBe(true);
|
|
||||||
expect(el("confirm-balance-error").textContent).toBe(
|
|
||||||
"Your TOK balance does not cover this amount. Please go back" +
|
|
||||||
" and send a smaller amount.",
|
|
||||||
);
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("of a token, states and checks that network's token balance as it is then, which now covers the send, and refuses to send it", async () => {
|
|
||||||
await review(reviewedOn, tokenSend);
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
mockNode.tokenUnits = {
|
|
||||||
[reviewedOn]: 10000000n,
|
|
||||||
[switchedTo]: 0n,
|
|
||||||
};
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
expect(shown("confirm-balance-error")).toBe(false);
|
|
||||||
expect(shown("confirm-gas-error")).toBe(false);
|
|
||||||
expect(sendDisabled()).toBe(false);
|
|
||||||
|
|
||||||
await signAndSend();
|
|
||||||
|
|
||||||
expect(signTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(mockNode.broadcast).toEqual([]);
|
|
||||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
|
|
||||||
await review(reviewedOn);
|
|
||||||
mockNode.legacyFees = true;
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(el("confirm-fee-amount").textContent).toBe(
|
|
||||||
"0.0002 " + symbol + usdOn(reviewedOn, "$0.42"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
|
|
||||||
mockNode.tokenUnits[reviewedOn] = 50000000n;
|
|
||||||
await review(reviewedOn, {
|
|
||||||
token: TOKEN,
|
|
||||||
tokenSymbol: "USDC",
|
|
||||||
tokenDecimals: 6,
|
|
||||||
amount: "10",
|
|
||||||
});
|
|
||||||
await switchReopenAndGoBack(reviewedOn, switchedTo);
|
|
||||||
|
|
||||||
expect(el("confirm-type").textContent).toBe(
|
|
||||||
"ERC-20 token transfer (USDC)",
|
|
||||||
);
|
|
||||||
expect(el("confirm-token-contract").innerHTML).toContain(
|
|
||||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
|
||||||
);
|
|
||||||
expect(el("confirm-amount").textContent).toBe(
|
|
||||||
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
|
||||||
);
|
|
||||||
expect(el("confirm-balance").textContent).toBe(
|
|
||||||
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The check in the click handler tells the user; the send itself carries the
|
|
||||||
// chain id the screen was drawn for, so a signer on another network's node
|
|
||||||
// cannot sign it even if the network changes after that check.
|
|
||||||
test("the send is refused by a signer on another network's node", async () => {
|
|
||||||
const { getProvider } = require("../src/shared/balances");
|
|
||||||
const signer = new Wallet(PRIVATE_KEY).connect(
|
|
||||||
getProvider("https://rpc.example.invalid", "mainnet"),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
confirmTx.populateVerifyAndSend(signer, {
|
|
||||||
token: "ETH",
|
|
||||||
to: RECIPIENT,
|
|
||||||
amount: "1",
|
|
||||||
chainId: NETWORKS.sepolia.chainId,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("transaction chainId mismatch");
|
|
||||||
expect(signTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(mockNode.broadcast).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the balance read", () => {
|
|
||||||
test("leaves the balance line Loading... and Sign & Send disabled until it returns", async () => {
|
|
||||||
let answer;
|
|
||||||
const getBalance = jest
|
|
||||||
.spyOn(JsonRpcProvider.prototype, "getBalance")
|
|
||||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
|
||||||
try {
|
|
||||||
await review("sepolia");
|
|
||||||
} finally {
|
|
||||||
getBalance.mockRestore();
|
|
||||||
}
|
|
||||||
expect(el("confirm-balance").textContent).toBe("Loading...");
|
|
||||||
expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
|
|
||||||
expect(el("confirm-errors").style.visibility).toBe("hidden");
|
|
||||||
expect(shown("confirm-balance-error")).toBe(false);
|
|
||||||
expect(shown("confirm-balance-read-error")).toBe(false);
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
|
|
||||||
answer(parseEther("5"));
|
|
||||||
await settle();
|
|
||||||
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
|
|
||||||
expect(sendDisabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The Chrome suite measures that nothing moves; this checks the message
|
|
||||||
// is already written, only hidden, before the read returns.
|
|
||||||
test("that finds the amount not covered only makes a message written before it visible", async () => {
|
|
||||||
let answer;
|
|
||||||
const getBalance = jest
|
|
||||||
.spyOn(JsonRpcProvider.prototype, "getBalance")
|
|
||||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
|
||||||
try {
|
|
||||||
await review("sepolia");
|
|
||||||
} finally {
|
|
||||||
getBalance.mockRestore();
|
|
||||||
}
|
|
||||||
const message =
|
|
||||||
"Your SepoliaETH balance does not cover this amount. Please go" +
|
|
||||||
" back and send a smaller amount.";
|
|
||||||
expect(el("confirm-balance-error").textContent).toBe(message);
|
|
||||||
expect(shown("confirm-balance-error")).toBe(false);
|
|
||||||
|
|
||||||
answer(parseEther("0.5"));
|
|
||||||
await settle();
|
|
||||||
expect(el("confirm-balance-error").textContent).toBe(message);
|
|
||||||
expect(shown("confirm-balance-error")).toBe(true);
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The fee is estimated once the read returns, after the switch, and still
|
|
||||||
// asks the node of the network the screen was drawn for.
|
|
||||||
test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => {
|
|
||||||
let answer;
|
|
||||||
const getBalance = jest
|
|
||||||
.spyOn(JsonRpcProvider.prototype, "getBalance")
|
|
||||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
|
||||||
try {
|
|
||||||
await review("mainnet");
|
|
||||||
} finally {
|
|
||||||
getBalance.mockRestore();
|
|
||||||
}
|
|
||||||
const feeAskedOn = () =>
|
|
||||||
mockNode.asked
|
|
||||||
.filter((q) => q.method === "getFeeData")
|
|
||||||
.map((q) => q.network);
|
|
||||||
expect(feeAskedOn()).toEqual([]);
|
|
||||||
|
|
||||||
await onChainSwitch("sepolia");
|
|
||||||
answer(parseEther("5"));
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(feeAskedOn()).toEqual(["mainnet"]);
|
|
||||||
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
|
||||||
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
|
|
||||||
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
|
||||||
expect(el("confirm-fee-reserve").textContent).toBe(
|
|
||||||
"up to 0.0004 ETH reserved",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("that fails states the balance as unknown, not zero, and blocks sending", async () => {
|
|
||||||
const getBalance = jest
|
|
||||||
.spyOn(JsonRpcProvider.prototype, "getBalance")
|
|
||||||
.mockRejectedValueOnce(new Error("the node did not answer"));
|
|
||||||
try {
|
|
||||||
await review("sepolia");
|
|
||||||
} finally {
|
|
||||||
getBalance.mockRestore();
|
|
||||||
}
|
|
||||||
expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)");
|
|
||||||
expect(shown("confirm-balance-read-error")).toBe(true);
|
|
||||||
expect(shown("confirm-balance-error")).toBe(false);
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
// A site allowed without "Remember my choice for this site" stays connected
|
|
||||||
// when the background worker is stopped and started again
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
|
|
||||||
//
|
|
||||||
// Chrome stops the worker after about 30 seconds idle and evaluates the
|
|
||||||
// module afresh on the next event, so nothing held at module scope survives
|
|
||||||
// it. Such a connection used to be held exactly there, and so lasted until the
|
|
||||||
// first idle spell. Each test here makes the connection in one load of the
|
|
||||||
// background, loads the module afresh over the same storage, as the browser
|
|
||||||
// does, and asks the new one. The paths that end a connection are driven after
|
|
||||||
// that restart, so each is shown to end what the earlier worker stored.
|
|
||||||
|
|
||||||
const { networkById } = require("../src/shared/networks");
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
const {
|
|
||||||
removeAddressFromState,
|
|
||||||
removeWalletFromState,
|
|
||||||
} = require("../src/shared/walletDelete");
|
|
||||||
|
|
||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
|
|
||||||
|
|
||||||
const SITE = "https://dapp.example";
|
|
||||||
const OTHER_SITE = "https://other.example";
|
|
||||||
|
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
|
||||||
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
|
|
||||||
|
|
||||||
// One wallet with two addresses, so that one of them can be removed, and no
|
|
||||||
// remembered sites, so that every connection here is one made without
|
|
||||||
// "Remember".
|
|
||||||
function storedProfile() {
|
|
||||||
const mainnet = networkById("mainnet");
|
|
||||||
return {
|
|
||||||
hasWallet: true,
|
|
||||||
wallets: [
|
|
||||||
{
|
|
||||||
name: "Wallet 1",
|
|
||||||
type: "hd",
|
|
||||||
addresses: [
|
|
||||||
{ address: ADDRESS, balance: "0", tokenBalances: [] },
|
|
||||||
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeAddress: ADDRESS,
|
|
||||||
networkId: "mainnet",
|
|
||||||
rpcUrl: mainnet.defaultRpcUrl,
|
|
||||||
blockscoutUrl: mainnet.defaultBlockscoutUrl,
|
|
||||||
allowedSites: {},
|
|
||||||
deniedSites: {},
|
|
||||||
trackedTokens: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.chrome;
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load the background module afresh, as the browser does when it starts the
|
|
||||||
// worker, over `storage`. The storage stub stands in for the browser's own
|
|
||||||
// storage, local and session, so it outlives each load.
|
|
||||||
function startWorker(storage) {
|
|
||||||
jest.resetModules();
|
|
||||||
jest.doMock("../src/shared/balances", () => ({
|
|
||||||
getProvider: () => ({}),
|
|
||||||
refreshBalances: jest.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
||||||
isPhishingDomain: () => false,
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/shared/alarms", () => ({
|
|
||||||
BALANCE_REFRESH_ALARM: "balance",
|
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
||||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
|
||||||
registerAlarmHandlers: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let messageListener = null;
|
|
||||||
let connectListener = null;
|
|
||||||
// Every URL the toolbar popup was set to open: a site's connection prompt
|
|
||||||
// is shown there, so this is where its approval id appears.
|
|
||||||
const toolbarPopups = [];
|
|
||||||
const windowsCreated = [];
|
|
||||||
const sentToTabs = [];
|
|
||||||
|
|
||||||
global.chrome = {
|
|
||||||
storage,
|
|
||||||
runtime: {
|
|
||||||
getURL: (path) => EXT_URL + path,
|
|
||||||
onMessage: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
messageListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onConnect: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
connectListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getPlatformInfo: (cb) => cb({ os: "linux" }),
|
|
||||||
// What the popup sends reaches the worker from the extension's own
|
|
||||||
// page.
|
|
||||||
sendMessage: (msg, callback) => {
|
|
||||||
messageListener(msg, FROM_POPUP, callback || (() => {}));
|
|
||||||
},
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
windows: {
|
|
||||||
getLastFocused: (cb) => cb(null),
|
|
||||||
create: (options, cb) => {
|
|
||||||
windowsCreated.push(options);
|
|
||||||
cb({ id: windowsCreated.length });
|
|
||||||
},
|
|
||||||
remove: (id, cb) => {
|
|
||||||
if (cb) cb();
|
|
||||||
},
|
|
||||||
onRemoved: { addListener: () => {} },
|
|
||||||
},
|
|
||||||
tabs: {
|
|
||||||
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
|
|
||||||
sendMessage: (tabId, msg, cb) => {
|
|
||||||
sentToTabs.push(msg);
|
|
||||||
if (cb) cb();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
setPopup: (o) => {
|
|
||||||
toolbarPopups.push(o.popup);
|
|
||||||
},
|
|
||||||
openPopup: () => Promise.resolve(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
require("../src/background/index");
|
|
||||||
|
|
||||||
// A request from a site, answered once the worker has had time to answer.
|
|
||||||
async function rpc(method, origin, params) {
|
|
||||||
let response = null;
|
|
||||||
messageListener(
|
|
||||||
{ type: "AUTISTMASK_RPC", method, params: params || [] },
|
|
||||||
{ origin },
|
|
||||||
(r) => {
|
|
||||||
response = r;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A message from the popup, and the worker's answer to it.
|
|
||||||
async function fromPopup(msg) {
|
|
||||||
let response;
|
|
||||||
messageListener(msg, FROM_POPUP, (r) => {
|
|
||||||
response = r;
|
|
||||||
});
|
|
||||||
await settle();
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The site asks to connect, and the user allows it in the prompt with
|
|
||||||
// "Remember my choice for this site" left unchecked.
|
|
||||||
async function connectWithoutRemember(origin) {
|
|
||||||
let response = null;
|
|
||||||
messageListener(
|
|
||||||
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
|
|
||||||
{ origin },
|
|
||||||
(r) => {
|
|
||||||
response = r;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
|
|
||||||
"approval",
|
|
||||||
);
|
|
||||||
const decisionListeners = [];
|
|
||||||
const port = {
|
|
||||||
name: "approval:" + id,
|
|
||||||
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
|
|
||||||
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
|
|
||||||
onDisconnect: { addListener: () => {} },
|
|
||||||
};
|
|
||||||
connectListener(port);
|
|
||||||
for (const fn of decisionListeners) {
|
|
||||||
fn(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_APPROVAL_DECISION",
|
|
||||||
approved: true,
|
|
||||||
remember: false,
|
|
||||||
},
|
|
||||||
port,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await settle();
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rpc,
|
|
||||||
fromPopup,
|
|
||||||
connectWithoutRemember,
|
|
||||||
windowsCreated,
|
|
||||||
sentToTabs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site connected without "Remember" in one worker, and the worker started
|
|
||||||
// again over the same storage, where the site is still connected.
|
|
||||||
async function connectedThenRestarted() {
|
|
||||||
const storage = makeStorageStub({ autistmask: storedProfile() });
|
|
||||||
const first = startWorker(storage);
|
|
||||||
expect(await first.connectWithoutRemember(SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
const worker = startWorker(storage);
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
return { storage, worker };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("a site allowed without Remember, after the worker restarts", () => {
|
|
||||||
test("is still told the address, and is not asked again", async () => {
|
|
||||||
const { worker } = await connectedThenRestarted();
|
|
||||||
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is still listed under Connected Sites in Settings", async () => {
|
|
||||||
const { worker } = await connectedThenRestarted();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
|
||||||
).toEqual([SITE]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("may still ask for a signature", async () => {
|
|
||||||
// Refused with 4100 before the fix. Allowed, it raises the signing
|
|
||||||
// prompt and waits on the user.
|
|
||||||
const { worker } = await connectedThenRestarted();
|
|
||||||
|
|
||||||
const response = await worker.rpc("personal_sign", SITE, [
|
|
||||||
"0x48656c6c6f",
|
|
||||||
ADDRESS,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(response).toBeNull();
|
|
||||||
expect(worker.windowsCreated).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is not connected after the browser restarts", async () => {
|
|
||||||
// The browser empties session storage when it closes; the wallet's
|
|
||||||
// own storage keeps nothing of the connection.
|
|
||||||
const { storage } = await connectedThenRestarted();
|
|
||||||
const afterBrowserRestart = startWorker(
|
|
||||||
makeStorageStub({ autistmask: storage.read("autistmask") }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
|
|
||||||
result: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
|
|
||||||
test("removing the site in Settings", async () => {
|
|
||||||
const { worker } = await connectedThenRestarted();
|
|
||||||
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
|
|
||||||
await worker.fromPopup({
|
|
||||||
type: "AUTISTMASK_REMOVE_SITE",
|
|
||||||
origin: SITE,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
|
||||||
expect(worker.sentToTabs).toEqual([
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
|
||||||
).toEqual([OTHER_SITE]);
|
|
||||||
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching to another address", async () => {
|
|
||||||
// Switched away and back again, so the answer turns on the connection
|
|
||||||
// having ended rather than on which address is active.
|
|
||||||
const { storage, worker } = await connectedThenRestarted();
|
|
||||||
const switched = { ...storage.read("autistmask") };
|
|
||||||
|
|
||||||
switched.activeAddress = OTHER_ADDRESS;
|
|
||||||
storage.write("autistmask", switched);
|
|
||||||
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
||||||
switched.activeAddress = ADDRESS;
|
|
||||||
storage.write("autistmask", switched);
|
|
||||||
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
|
||||||
expect(
|
|
||||||
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The two below ask while storage still names the removed address as
|
|
||||||
// active, because the popup has not saved yet, so the answer turns on the
|
|
||||||
// connection alone.
|
|
||||||
test("removing the connected address", async () => {
|
|
||||||
const { storage, worker } = await connectedThenRestarted();
|
|
||||||
|
|
||||||
const popupState = storage.read("autistmask");
|
|
||||||
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing the wallet holding the connected address", async () => {
|
|
||||||
const { storage, worker } = await connectedThenRestarted();
|
|
||||||
|
|
||||||
removeWalletFromState(storage.read("autistmask"), 0);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a different address leaves it connected", async () => {
|
|
||||||
const { storage, worker } = await connectedThenRestarted();
|
|
||||||
|
|
||||||
const popupState = storage.read("autistmask");
|
|
||||||
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
|
|
||||||
result: [ADDRESS],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -192,7 +192,6 @@ describe("the wait, success and error screens", () => {
|
|||||||
amount: "0.0000",
|
amount: "0.0000",
|
||||||
token: "ETH",
|
token: "ETH",
|
||||||
tokenSymbol: null,
|
tokenSymbol: null,
|
||||||
chainId: "0x1",
|
|
||||||
};
|
};
|
||||||
const transfer = { ...creation, to: RECIPIENT };
|
const transfer = { ...creation, to: RECIPIENT };
|
||||||
|
|
||||||
@@ -297,8 +296,6 @@ describe.each([
|
|||||||
state.selectedWallet = 0;
|
state.selectedWallet = 0;
|
||||||
state.selectedAddress = 0;
|
state.selectedAddress = 0;
|
||||||
state.selectedToken = "ETH";
|
state.selectedToken = "ETH";
|
||||||
// The history is Blockscout's.
|
|
||||||
state.useBlockscout = true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
||||||
|
|||||||
@@ -253,42 +253,12 @@ describe("reaching the screen", () => {
|
|||||||
const { decryptWithPassword } = require("../src/shared/vault");
|
const { decryptWithPassword } = require("../src/shared/vault");
|
||||||
decryptWithPassword.mockRejectedValue(new Error("nope"));
|
decryptWithPassword.mockRejectedValue(new Error("nope"));
|
||||||
await click("btn-delete-wallet-confirm");
|
await click("btn-delete-wallet-confirm");
|
||||||
expect(node("delete-wallet-password-error").textContent).toBe(
|
expect(node("delete-wallet-flash").textContent).toBe(
|
||||||
"That password is incorrect. Please try again.",
|
"That password is incorrect. Please try again.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Both screens tell the user what they need to get the wallet back, and a
|
|
||||||
// key or xprv wallet has no recovery phrase: telling its owner to rely on
|
|
||||||
// one sends them looking for words that do not exist
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/529).
|
|
||||||
describe("what the screens say the wallet's backup is", () => {
|
|
||||||
test.each([
|
|
||||||
["hd", "recovery phrase"],
|
|
||||||
["key", "private key"],
|
|
||||||
["xprv", "extended private key"],
|
|
||||||
])(
|
|
||||||
"a wallet of type %s is told about its %s",
|
|
||||||
async (type, keyMaterial) => {
|
|
||||||
const { deleteWallet, state } = load();
|
|
||||||
state.wallets[1].type = type;
|
|
||||||
|
|
||||||
deleteWallet.show(1);
|
|
||||||
expect(state.currentView).toBe("delete-wallet-confirm");
|
|
||||||
expect(node("delete-wallet-key-material").textContent).toBe(
|
|
||||||
keyMaterial,
|
|
||||||
);
|
|
||||||
|
|
||||||
await click("btn-delete-wallet-lost-password");
|
|
||||||
expect(state.currentView).toBe(VIEW);
|
|
||||||
expect(node("delete-wallet-lost-key-material").textContent).toBe(
|
|
||||||
keyMaterial,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the typed confirmation", () => {
|
describe("the typed confirmation", () => {
|
||||||
test("a name that is not the wallet's deletes nothing", async () => {
|
test("a name that is not the wallet's deletes nothing", async () => {
|
||||||
const { deleteWallet, state, storage } = load();
|
const { deleteWallet, state, storage } = load();
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe("the flash line the message is shown in", () => {
|
|||||||
//
|
//
|
||||||
// The line cuts a message too long for it with an ellipsis (see
|
// The line cuts a message too long for it with an ellipsis (see
|
||||||
// showFlash() in src/popup/views/helpers.js). The assertions that
|
// showFlash() in src/popup/views/helpers.js). The assertions that
|
||||||
// measure that, in a real browser at the documented 396x600 popup, are
|
// measure that, in a real browser at the documented 360x600 popup, are
|
||||||
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
||||||
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
||||||
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
// A loopback dApp origin and stub Ethereum node, used by the two browser
|
// A loopback dApp origin and stub Ethereum node for the Firefox suite.
|
||||||
// programs that have no Playwright to answer the browser's requests for them:
|
|
||||||
// the Firefox suite (tests/e2e/firefox/run.js) and the Chrome program that
|
|
||||||
// leaves approvals open for seven minutes (tests/e2e/slowApproval.js). The
|
|
||||||
// Chrome suite in run.js answers its requests through network.js instead.
|
|
||||||
//
|
//
|
||||||
// The Firefox container runs with --network none, and the harness note in
|
// The Firefox container runs with --network none, and the harness note in
|
||||||
// firefox/driver.js records the consequence: with no http:// origin in reach,
|
// driver.js records the consequence: with no http:// origin in reach, no
|
||||||
// no content script was ever injected, so content-script behaviour was
|
// content script was ever injected, so content-script behaviour was
|
||||||
// UNVERIFIED and the dApp flows could not be driven at all.
|
// UNVERIFIED and the dApp flows could not be driven at all.
|
||||||
//
|
//
|
||||||
// --network none removes every interface except loopback, and loopback is
|
// --network none removes every interface except loopback, and loopback is
|
||||||
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
|
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
|
||||||
// inside the same container the browser runs in, so the dApp round trips
|
// inside the same container Firefox runs in, so the dApp round trips execute
|
||||||
// execute against a real http:// origin and the run stays as offline as it
|
// against a real http:// origin and the run stays as offline as it was: the
|
||||||
// was: the only reachable peer is this process.
|
// only reachable peer is this process.
|
||||||
//
|
//
|
||||||
// The page itself is not written twice. DAPP_HTML comes from the Chrome
|
// The page itself is not written twice. DAPP_HTML comes from the Chrome
|
||||||
// suite's fixture, so every browser program drives the same __dapp API and
|
// suite's fixture, so both harnesses drive the same __dapp API and the same
|
||||||
// the same message log.
|
// message log.
|
||||||
//
|
//
|
||||||
// Unlike firefox/driver.js this file does use ethers, and it has to: the node
|
// Unlike driver.js this file does use ethers, and it has to: the node has to
|
||||||
// has to answer eth_sendRawTransaction with the hash ethers computes for the
|
// answer eth_sendRawTransaction with the hash ethers computes for the
|
||||||
// artifact it was handed, or provider.broadcastTransaction() refuses the
|
// artifact it was handed, or provider.broadcastTransaction() refuses the
|
||||||
// answer, and both programs recover signatures themselves rather than
|
// answer, and the suite recovers signatures itself rather than believing the
|
||||||
// believing the extension's own verdict.
|
// extension's own verdict.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -31,7 +27,7 @@ const http = require("http");
|
|||||||
|
|
||||||
const { Transaction } = require("ethers");
|
const { Transaction } = require("ethers");
|
||||||
|
|
||||||
const { DAPP_HTML } = require("./network");
|
const { DAPP_HTML } = require("../network");
|
||||||
|
|
||||||
// The same fee shape the Chrome suite uses, for the same reason: it has to
|
// The same fee shape the Chrome suite uses, for the same reason: it has to
|
||||||
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
|
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
|
||||||
@@ -174,11 +170,11 @@ async function startDappServer() {
|
|||||||
const payload = JSON.stringify(handleRpcBody(body, state));
|
const payload = JSON.stringify(handleRpcBody(body, state));
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
// In Firefox the extension fetches this from its
|
// The extension fetches this from its background
|
||||||
// background page, whose origin is moz-extension://.
|
// page, whose origin is moz-extension://. Without CORS
|
||||||
// Without CORS the fetch fails and every transaction
|
// the fetch fails and every transaction assertion
|
||||||
// assertion fails for a reason that has nothing to do
|
// fails for a reason that has nothing to do with the
|
||||||
// with the wallet.
|
// wallet.
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
});
|
});
|
||||||
res.end(payload);
|
res.end(payload);
|
||||||
@@ -197,8 +193,8 @@ async function startDappServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty favicon rather than a 404: a 404 is a page error in
|
// An empty favicon rather than a 404: a 404 is a page error in
|
||||||
// Firefox's console under some settings, and the Firefox suite fails
|
// Firefox's console under some settings, and the suite fails the run
|
||||||
// the run on those.
|
// on those.
|
||||||
if (url.pathname === "/favicon.ico") {
|
if (url.pathname === "/favicon.ico") {
|
||||||
res.writeHead(200, { "Content-Type": "image/x-icon" });
|
res.writeHead(200, { "Content-Type": "image/x-icon" });
|
||||||
res.end("");
|
res.end("");
|
||||||
@@ -279,18 +279,12 @@ class Driver {
|
|||||||
|
|
||||||
// Shown means shown: in the popup a view is switched by toggling a
|
// Shown means shown: in the popup a view is switched by toggling a
|
||||||
// "hidden" class, and an element that is present but collapsed is not
|
// "hidden" class, and an element that is present but collapsed is not
|
||||||
// the thing a test means by visible. Until the page's stylesheet has
|
// the thing a test means by visible.
|
||||||
// applied that class hides nothing and every view lays out, so the page
|
|
||||||
// must also have finished loading, which it does only after its
|
|
||||||
// stylesheet, and neither the element nor anything enclosing it may
|
|
||||||
// carry the class (https://git.eeqj.de/sneak/AutistMask/issues/502).
|
|
||||||
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
|
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
|
||||||
return this.waitFor(
|
return this.waitFor(
|
||||||
"selector " + selector + " to be visible",
|
"selector " + selector + " to be visible",
|
||||||
`const el = document.querySelector(arguments[0]);
|
`const el = document.querySelector(arguments[0]);
|
||||||
if (document.readyState !== "complete" || !el) return false;
|
if (!el) return false;
|
||||||
if (el.closest(".hidden")) return false;
|
|
||||||
if (getComputedStyle(el).visibility !== "visible") return false;
|
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
return r.width > 0 && r.height > 0;`,
|
return r.width > 0 && r.height > 0;`,
|
||||||
[selector],
|
[selector],
|
||||||
@@ -467,7 +461,7 @@ class Driver {
|
|||||||
// src/background/index.js, which kills the background page outright, fails
|
// src/background/index.js, which kills the background page outright, fails
|
||||||
// the run.
|
// the run.
|
||||||
//
|
//
|
||||||
// Content scripts ARE now exercised: tests/e2e/dapp.js serves a page
|
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
|
||||||
// from loopback, which survives --network none, and the suite drives the
|
// from loopback, which survives --network none, and the suite drives the
|
||||||
// EIP-1193 round trips through the content script injected into it. What is
|
// EIP-1193 round trips through the content script injected into it. What is
|
||||||
// still unproven is the CAPTURE, not the execution — no probe has forced a
|
// still unproven is the CAPTURE, not the execution — no probe has forced a
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user