Author SHA1 Message Date
sneak 731089e4a2 fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ConfirmTx showed and checked the balance Send handed it, so a popup reopened onto a confirmation saved before a network switch stated the other network's balance and checked the amount against it.

Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node of the network it is drawn for, and gives that node to the fee estimate, which starts once the read is done. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns.

Model: opus-5-5
2026-10-19 02:09:35 +00:00
clawbot 85fa864db4 test: no timer a popup test started fires after cleanupPopup() (closes #578)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
tests/support/popupBoot.js recorded every interval the popup sets but left
timeouts alone, so the two-second timer that clears a flash message could fire
after cleanupPopup() had removed the page, throw "document is not defined",
and fail whichever test file the jest worker had moved on to. Timeouts still
run for real during a popup test; the helper now records each one until it
fires or is cleared, and cleanupPopup() clears the ones still pending. The
fake timers tests/firstWalletRefresh.test.js used to hold that timer are
removed. tests/popupBoot.test.js shows a flash message, cleans up, waits past
the flash duration, and fails if anything throws.

Model: opus-5-5
2026-10-19 04:06:24 +02:00
clawbot d2fd0f532a fix: a popup that opened on Welcome keeps refreshing once the first wallet is added (closes #530)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The ten-second refresh was started only by init(), and only when a wallet
existed as the popup opened, so a popup that opened on Welcome refreshed the
new wallet once and then showed that balance until it was reopened. init()
and the three import paths on AddWallet now call startRefresh(), which
refreshes at once and starts the ten-second refresh unless it is already
running, and does nothing while the recovery screen is up. The two AddWallet
tests that build their own context pass it in place of the refresh they
passed before.

Model: opus-5-5
2026-10-19 03:19:53 +02:00
clawbot b923b2c19c fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A V4 step's input side is what SETTLE or SETTLE_ALL names, else the one
currency its swap actions spend and never buy; its output side is what
TAKE or TAKE_ALL names, else the one they buy and never spend. Amount sums
the amountIn of the actions spending the input side, shown only when each
states one; Min. received likewise. An unread swap action leaves the sides
to SETTLE, SETTLE_ALL, TAKE and TAKE_ALL, with no figure. A step with a
swap action always sets the output side, sets the input side only if no
earlier step did, and leaves a side its rules cannot name unknown, with no
figure. SWAP_EXACT_OUT spends its first path entry.

Model: opus-5-5
2026-10-19 02:50:16 +02:00
clawbot e2e1c5d40e fix: the recipient warning says what it checked and skips addresses that have received ETH (closes #528)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The transaction count the node reports is what an address has sent, so
an address that only receives, such as a savings address, got the "ZERO
transaction history" box on every send. The check now also asks the node
for the recipient's balance and warns only when both are zero. The box
says the address has never sent a transaction and holds no ETH, naming
the active network's native token as the contract warning does.

Model: opus-5-5
2026-10-19 02:34:13 +02:00
clawbot fc042c3191 fix: a page whose origin has no host is never told an address by accountsChanged (closes #564)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The background judges each tab by its URL's origin, the only one it can see, so a page its server sandboxes, whose own origin is null, was still sent the new active address on an address switch although all its requests are refused.

The background now names the origin it judged by on each accountsChanged event, and the content script passes the address to the page only when the page's own window.origin is that origin, an empty list otherwise. An event naming no origin also reaches the page as an empty list. chainChanged is unchanged. The README's SiteApproval section says the same.

Judgement call: covered by a unit test that sets window.origin; no browser-suite test.

Model: opus-5-5
2026-10-19 00:59:39 +02:00
clawbot 8b0a1bff02 fix: a site allowed without Remember stays connected when the background worker idles (closes #526)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A connection allowed without "Remember my choice for this site" lived in a module-level object in the background, so it ended the first time Chrome stopped the idle worker. Each such connection is now one item in the browser's session storage, which outlives the worker and is emptied when the browser closes, read and written through new wrappers in src/shared/browserApi.js. Removal in Settings, address switch, address removal and wallet removal still end it; Settings now waits for the removal before redrawing.

manifest/firefox.json now declares a minimum of Firefox 115.0, the first with session storage.

Judgement call: address and wallet removal still notify the background without waiting.

Model: opus-5-5
2026-10-18 23:59:24 +02:00
clawbot 1f5ec1c8de fix: Send refuses every amount the confirmation screen would refuse (closes #532)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Send's Review accepted any amount parseFloat() read as above zero, so 1e-3, 1abc and amounts with too many decimal places reached ConfirmTx, which then refused them or failed to estimate the fee.

isValidAmount() in src/shared/txValidation.js is now the one amount check, used by Send's Review and Max and by validateTransfer() for ConfirmTx: plain digits with at most one decimal point, above zero, nothing but zeros past the token's places. Zeros past those places are accepted, since the transfer encodes the amount exactly. Send now reads the token's decimal places before the ENS lookup, because the check needs them.

Judgement call: ConfirmTx now also refuses zero, as Send already did.

Model: opus-5-5
2026-10-18 22:46:07 +02:00
clawbot f00954e2c5 fix: failed requests reach the page with an error code and the node's data (closes #533)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A request the node refuses now reaches the page with the node's own JSON-RPC code and data: a reverting eth_call, an eth_sendTransaction whose gas estimate reverts, a failed broadcast. Every other refusal from the background carries a code too: 4100 when there is no address, -32603 for a failure inside the wallet, -32602 for a transaction request the wallet refuses as malformed. On the transaction path the page keeps the wallet's own sentence.

nodeError(), which finds the node's error inside an ethers error, lives in src/shared/approvalVerify.js beside failureTexts(), which did the same lookup.

Not run in a browser.

Model: opus-5-5
2026-10-18 22:12:10 +02:00
clawbot e48308c5b6 fix: the first wallet's first address is active as soon as it is added (closes #512)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The popup picked an active address only when it opened, so after the first
wallet was created nothing was active until a reopen: Home had no headline,
and Send and Receive refused. Each of the three import paths on AddWallet now
makes the new wallet's first address active when no address is. When the
phrase or xprv scan then replaces the address list with one that leaves that
address out, the new list's first address becomes active and the background
is told, as when the user picks an address on Home. The address scan stand-in
in the popup test boot now answers in the real scan's shape, which the phrase
and xprv imports read.

Model: opus-5-5
2026-10-16 07:15:29 +02:00
clawbot aa10a2a5f5 fix: each browser-suite run builds and removes an image of its own (closes #547)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
script/test-e2e and script/test-e2e-firefox tagged their image with one
fixed name. When two clones ran a suite at once, the second build moved
that tag, the image store deleted the first run's now-untagged image, and
that suite exited 125 without running a test. Each run now tags its image
with the suite's name and twelve random hex digits and runs it by that
tag. The cleanup that runs on exit, Ctrl-C, kill and a closed terminal
removes the run's container if docker's client left it running, then the
image. The image-ID file that guarded against a moving tag is gone.
README.md's End-to-End Tests section says which endings clean up.

Model: opus-5-5
2026-10-16 06:51:08 +02:00
44 changed files with 3321 additions and 651 deletions
+126 -60
View File
@@ -362,6 +362,14 @@ are outside `make check`. Neither opens the popup from the toolbar button: both
load its page in an ordinary tab, so what the toolbar popup itself adds, its load its page in an ordinary tab, so what the toolbar popup itself adds, its
size and its closing when it loses focus, is covered by neither. size and its closing when it loses focus, is covered by neither.
Each run builds its image under a tag of its own, `autistmask-e2e-chrome:` or
`autistmask-e2e-firefox:` followed by twelve random hex digits, so two clones
can run the same suite at once without one build taking the other's image away.
The run removes that image, and its container if one is still running, after the
suite passes or fails, on Ctrl-C, on `kill`, and when the terminal or SSH
session it runs in closes. A run killed outright (`kill -9`) cannot clean up:
its image stays under its tag until removed by hand with `docker image rm`.
### Chrome (`make test-e2e`) ### Chrome (`make test-e2e`)
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real `make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -563,9 +571,12 @@ the worker stopping. It starts Chromium itself, speaks the DevTools protocol
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
approvals it requires the worker to stop on its own with nothing pending, so a approvals it requires the worker to stop on its own with nothing pending, so a
run in which something else keeps the worker running fails. The site is run in which something else keeps the worker running fails. The site is
connected and the RPC endpoint set through the extension's own screens, against connected, without "Remember my choice for this site", and the RPC endpoint set
the loopback dApp page and stub node the Firefox suite also uses through the extension's own screens, against the loopback dApp page and stub
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors. node the Firefox suite also uses (`tests/e2e/dapp.js`). Once the worker has
stopped, the run requires the site still to be told the address
([#526](https://git.eeqj.de/sneak/AutistMask/issues/526)). Unlike the suite
above, it does not fail on page errors.
### Firefox (`make test-e2e-firefox`) ### Firefox (`make test-e2e-firefox`)
@@ -849,7 +860,11 @@ on the next event. Two consequences shape every recurring job in the background:
- Module-level variables do not survive either. Anything that must be remembered - Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage. `localStorage` does not exist in a across a restart goes in extension storage. `localStorage` does not exist in a
service worker at all — the one remaining user of it, `src/shared/ens.js`, service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such. runs only in the popup and is marked as such. The sites allowed without
"Remember my choice for this site" are kept in the browser's session storage
(`storage.session`), which outlives the worker but not the browser, so such a
connection lasts until it is ended or the browser closes. Firefox has session
storage from version 115, the minimum `manifest/firefox.json` declares.
Pending approvals are the exception: an approval waiting for the user, and the Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the site's request waiting on it, live only in the worker's memory, and only the
@@ -998,14 +1013,16 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The `src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the wallet's balances and the Send screen, which sends on the active network, use
active network, use the active network's. A transaction's figures use the one of the active network's. The confirmation screen uses the one of the network it is
drawn for, the network active when it was shown, even after the network is
switched in Settings while it is open. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native document shows ETH as the label of a native balance, value or fee, in a "Native
ETH transfer" type line, in the contract-recipient warning or in that refusal, ETH transfer" type line, in the two recipient warnings or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording. its fixed wording.
@@ -1153,11 +1170,13 @@ read:
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in, there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its or V4 swap step that names an input token or amount. A V4 step with a swap
`amountInMax`, and a V4 swap step the `amountIn` of its first readable action sets the input side when no earlier step set it, even when it names
exact-in action. neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's - `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the The router reads that zero as "the pair already holds the input tokens": the
@@ -1179,16 +1198,38 @@ The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does, with a swap action always sets the output side, and sets the input side only
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's when no earlier step set it. It finds each side by three rules:
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set 1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise the one currency that some swap action in the step spends and none buys. Its
`Token Out` and `Min. received` keep the output side's own token and figure, output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys currency that some swap action buys and none spends. If there is not exactly
USDC and then unwraps the WETH it did not spend shows USDC. The token permission one such currency, that side is unknown, and the step sets it as unknown with
warning on the signature screen has its own amount wording, including `Unknown`; no figure. A step that buys WETH with USDT and USDC with that WETH, with
the SignApproval section below describes it. nothing naming its sides, reads `Swap USDT → USDC`.
2. Its `Amount` is the `amountIn` of the swap action that spends the input
currency. If several exact-in actions spend it, each with a stated amount, it
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
step sets it as unknown with no figure.
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g. The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00 `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1678,7 +1719,13 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Review" with an unresolvable ENS name or an invalid amount → flash - "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change message, no screen change. An amount is valid when it is a number above
zero, written as digits with at most one decimal point, with nothing but
zeros after the token's last decimal place. ETH has 18 places, and a token
with more, or with an unknown number, is held to 18. Zeros past those
places leave the amount unchanged, so `1.0000000` of a 6-place token
passes and is sent as exactly 1. ConfirmTx makes the same check before
anything can be sent
- "Back" → previous screen (Home, AddressDetail, or AddressToken) - "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### ConfirmTx (`confirm-tx`) #### ConfirmTx (`confirm-tx`)
@@ -1695,13 +1742,21 @@ view would leave a wallet one click from deletion.
arrives, as the balance minus the reserve, and the transaction is signed arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance no longer covers the reserve the transaction does not use. If the balance could not be read
the fee, the amount is left as it was and the amount-plus-fee error below or no longer covers the fee, the amount is left as it was and one of the
blocks the send errors below blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)` - Your balance: the From address's balance on the network the screen is
for a token whose scale is unknown drawn for, read from that network's RPC endpoint each time the screen is
shown, so a screen the popup reopens onto after the network was switched
states and checks the balance on the new network. "Loading..." until the
read returns, then value + symbol (USD in parentheses), or
`unknown (SYMBOL)` for a token whose scale is unknown or when the read
failed. The amount check uses the same balances: the ETH balance, and for
a token transfer the token balance as well
- Network fee: "Estimating..." then two lines, or "Unable to estimate", - Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost, fetched async once the balance is read, from the same RPC endpoint as the
balance, even if the network is switched in Settings while the balance is
being read. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the `gasLimit * gasPrice` (USD in parentheses); the second is the
`gasLimit * maxFeePerGas` reserve the node requires, which is what the `gasLimit * maxFeePerGas` reserve the node requires, which is what the
balance check gates on. The second line is omitted on a network with no balance check gates on. The second line is omitted on a network with no
@@ -1709,21 +1764,26 @@ view would leave a wallet one click from deletion.
reserved either way reserved either way
- Warnings: inline warnings from the local checks (scam address, self-send) - Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks — plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn recipient that has never sent a transaction and holds no ETH, recipient is
address, and an Etherscan phishing/scam label a contract, burn address, and an Etherscan phishing/scam label. An address
- Errors (insufficient balance), plus three reserved error boxes — the that has received ETH but never sent a transaction still holds that ETH,
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to so it gets no warning
pay the fee for the transfer (ERC-20 transfers), and the fee could not be - Errors (an invalid amount), plus five reserved error boxes, so the balance
estimated. The first two are mutually exclusive per transfer type, so only and the fee arriving later move nothing — the amount exceeds the balance
the applicable one holds space. The last names its cause: for a token (for a token whose scale is unknown it says the token's balance is
whose scale is unknown the fee can never be estimated, and it says the unknown), the balance could not be read (it asks the user to go back and
wallet does not know how many decimal places the token uses and that the try again), the amount plus the fee exceeds the balance (ETH transfers),
transaction cannot be sent; for any other failure it asks the user to go not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
back and try again fee could not be estimated. The third and fourth are mutually exclusive
per transfer type, so only the applicable one holds space. The last names
its cause: for a token whose scale is unknown the fee can never be
estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Password: an inline field on this screen, not a modal, with its own error - Password: an inline field on this screen, not a modal, with its own error
line line
- "Sign & Send" button (disabled if errors, and while the network fee - "Sign & Send" button (disabled if errors, while the balance is being read,
estimate is pending or unavailable) and while the network fee estimate is pending or unavailable)
- **Transitions**: - **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
@@ -1922,7 +1982,7 @@ view would leave a wallet one click from deletion.
under any address, with remove buttons under any address, with remove buttons
- Connected Sites: the origins of the sites allowed without "Remember my - Connected Sites: the origins of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with holds these, in session storage, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES` `AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the origins remembered as denied, under any address, with - Denied Sites: the origins remembered as denied, under any address, with
remove buttons remove buttons
@@ -1941,9 +2001,10 @@ view would leave a wallet one click from deletion.
its origin is dropped from Allowed Sites under every address, and its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved `AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from that origin, under any address, and send without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. The `accountsChanged` with an empty list to the open tabs of that origin.
same host under another scheme or port is another site and is left alone. Settings waits for the background's answer before it draws the lists
Only the extension's own pages may send either message again. The same host under another scheme or port is another site and is
left alone. Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects - `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change) - Ten clicks on the version → reveals the Debug well (no screen change)
@@ -2004,8 +2065,8 @@ view would leave a wallet one click from deletion.
(`src/shared/walletDelete.js`) (`src/shared/walletDelete.js`)
- Either way, every address the wallet held loses its site permissions of - Either way, every address the wallet held loses its site permissions of
both kinds: the remembered ones in storage, and the connections approved both kinds: the remembered ones in storage, and the connections approved
without "Remember", which only the background holds, in memory, and drops without "Remember", which only the background holds, in session storage,
on `AUTISTMASK_ADDRESSES_REMOVED` and drops on `AUTISTMASK_ADDRESSES_REMOVED`
- "Confirm Delete" (wrong password) → "That password is incorrect. Please - "Confirm Delete" (wrong password) → "That password is incorrect. Please
try again." on the error line, nothing deleted try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword** - "I have lost my password" → **DeleteWalletLostPassword**
@@ -2118,8 +2179,8 @@ view would leave a wallet one click from deletion.
one in this wallet follows the splice. one in this wallet follows the splice.
- The address loses its site permissions of both kinds, whether or not it was - The address loses its site permissions of both kinds, whether or not it was
the active one: the remembered ones in storage, and any connection approved the active one: the remembered ones in storage, and any connection approved
without "Remember", which only the background holds, in memory, and drops on without "Remember", which only the background holds, in session storage, and
`AUTISTMASK_ADDRESSES_REMOVED`. drops on `AUTISTMASK_ADDRESSES_REMOVED`.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a - The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed. fresh address rather than handing back the one just removed.
@@ -2153,14 +2214,17 @@ view would leave a wallet one click from deletion.
code `4100` and opens no prompt. Two such origins are `null`, which the code `4100` and opens no prompt. Two such origins are `null`, which the
browser reports for a page with an opaque origin, such as one its server browser reports for a page with an opaque origin, such as one its server
sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports
for a local file. The background script prefers the toolbar popup for a local file. Nor is such a page told the active address by
(`action.openPopup()`) and falls back to a separate popup window `accountsChanged`, even at a connected site's URL: the background names the
(`src/background/index.js`, `requestApproval()`). Only one exists per site at origin of the tab's URL on the event, and the content script passes the
a time: a further connection request from a site whose prompt is still address on only to a page whose own origin is that one. The background script
unanswered is refused with EIP-1193 code `-32002` and opens no new prompt. If prefers the toolbar popup (`action.openPopup()`) and falls back to a separate
that prompt was in a toolbar popup that closed before it connected, and the popup window (`src/background/index.js`, `requestApproval()`). Only one exists
toolbar popup has since been set to open something else, the refused request per site at a time: a further connection request from a site whose prompt is
shows that prompt again. still unanswered is refused with EIP-1193 code `-32002` and opens no new
prompt. If that prompt was in a toolbar popup that closed before it connected,
and the toolbar popup has since been set to open something else, the refused
request shows that prompt again.
- **Elements**: - **Elements**:
- "Connection Request" heading - "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
@@ -2192,8 +2256,10 @@ view would leave a wallet one click from deletion.
one shown and signed in place of the node's estimate, but the node still one shown and signed in place of the node's estimate, but the node still
estimates the transaction with that limit, as the check that it neither estimates the transaction with that limit, as the check that it neither
reverts nor runs out of gas; a request that gives both with different values reverts nor runs out of gas; a request that gives both with different values
is refused. A request that cannot be populated — unreachable node, reverting is refused with EIP-1474 code `-32602`. A request that cannot be populated —
gas estimate — opens no window and is failed back to the site. Only one unreachable node, reverting gas estimate — opens no window and is failed back
to the site, with the node's own error code and data when the node refused it,
so the site can decode a revert, and with `-32603` otherwise. Only one
transaction approval exists at a time: populating fixes the nonce, so a second transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with `eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
+2 -1
View File
@@ -27,7 +27,8 @@
"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"
} }
} }
} }
+30 -17
View File
@@ -22,13 +22,29 @@ 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)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome" # Each run builds, runs and then removes an image under a tag of its own.
# Under one tag shared by every run, a second clone's build moves the tag
IIDFILE="" # off this run's image, and the image store can then delete that untagged
# 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() {
if [ -n "$IIDFILE" ]; then # After a third Ctrl-C docker's client exits whether or not the
rm -f "$IIDFILE" # container has stopped, and a container still running keeps the image
# 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
} }
@@ -40,22 +56,16 @@ main() {
exit 1 exit 1
fi fi
IIDFILE="$(mktemp)"
trap cleanup EXIT trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM # HUP: the terminal or SSH session running the suite closed.
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 --iidfile "$IIDFILE" -t "$IMAGE" \ docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
-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
@@ -75,11 +85,12 @@ 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}" \
"$(cat "$IIDFILE")" \ "$IMAGE" \
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
@@ -93,9 +104,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 \
"$(cat "$IIDFILE")" \ "$IMAGE" \
node tests/e2e/storagePartition.js node tests/e2e/storagePartition.js
# Approvals left open for seven minutes in minimized windows, past the # Approvals left open for seven minutes in minimized windows, past the
@@ -105,9 +117,10 @@ main() {
# attached to. It reaches nothing but its own loopback server. # attached to. It reaches nothing but its own loopback server.
echo "Running the approvals left open for seven minutes..." echo "Running the approvals left open for seven minutes..."
docker run --rm \ docker run --rm \
--name "$CONTAINER" \
--ipc=host \ --ipc=host \
-e HOME=/tmp \ -e HOME=/tmp \
"$(cat "$IIDFILE")" \ "$IMAGE" \
node tests/e2e/slowApproval.js node tests/e2e/slowApproval.js
} }
+22 -16
View File
@@ -21,13 +21,23 @@ 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)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox" # A tag and a container name of this run's own, as in script/test-e2e,
# which says why.
IIDFILE="" SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IMAGE="$NAME:$SUFFIX"
CONTAINER="$NAME-$SUFFIX"
cleanup() { cleanup() {
if [ -n "$IIDFILE" ]; then # After a third Ctrl-C docker's client exits whether or not the
rm -f "$IIDFILE" # container has stopped, and a container still running keeps the image
# 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
} }
@@ -39,22 +49,16 @@ main() {
exit 1 exit 1
fi fi
IIDFILE="$(mktemp)"
trap cleanup EXIT trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM # HUP: the terminal or SSH session running the suite closed.
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 --iidfile "$IIDFILE" -t "$IMAGE" \ docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
-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
@@ -69,10 +73,11 @@ 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 \
"$(cat "$IIDFILE")" \ "$IMAGE" \
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
@@ -82,10 +87,11 @@ 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 \
"$(cat "$IIDFILE")" \ "$IMAGE" \
node tests/e2e/firefox/reinstall.js node tests/e2e/firefox/reinstall.js
} }
+130 -60
View File
@@ -26,6 +26,7 @@ const {
typedDataMessageRefusal, typedDataMessageRefusal,
typedDataChainRefusal, typedDataChainRefusal,
failureIsRetryable, failureIsRetryable,
nodeError,
describeTxFailure, describeTxFailure,
sameAddress, sameAddress,
ApprovalMismatchError, ApprovalMismatchError,
@@ -48,6 +49,9 @@ const {
actionApi, actionApi,
getPlatformInfo, getPlatformInfo,
runtimeApi, runtimeApi,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
tabsQuery, tabsQuery,
tabsSendMessage, tabsSendMessage,
windowsApi, windowsApi,
@@ -60,12 +64,31 @@ const runtime = runtimeApi();
const windowsNs = windowsApi(); const windowsNs = windowsApi();
const actionNs = actionApi(); const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true } // Connections the user allowed without "Remember my choice for this site".
// 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.
const connectedSites = {}; // An origin can carry a port, so the address is what follows the key's last
// 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 = {};
@@ -272,6 +295,21 @@ 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
@@ -317,7 +355,8 @@ function extractHostname(origin) {
} }
} }
// Proxy an RPC call to the Ethereum node // Proxy an RPC call to the Ethereum node. An error the node answers with is
// 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, {
@@ -332,7 +371,10 @@ async function proxyRpc(method, params) {
}); });
const json = await resp.json(); const json = await resp.json();
if (json.error) { if (json.error) {
throw new Error(json.error.message || "RPC error"); const err = 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;
} }
@@ -728,7 +770,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: { message: "No accounts available" } }; return { error: { code: 4100, message: "No accounts available" } };
} }
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
@@ -744,10 +786,10 @@ async function handleConnectionRequest(origin) {
}; };
} }
// Check allowed list or in-memory connected // Check allowed list or a connection allowed without "Remember"
if ( if (
allowed.includes(origin) || allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress] (await isConnected(origin, activeAddress))
) { ) {
return { result: [activeAddress] }; return { result: [activeAddress] };
} }
@@ -784,7 +826,7 @@ async function handleConnectionRequest(origin) {
if (decision.remember) { if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin); await rememberSiteChoice("allowedSites", activeAddress, origin);
} else { } else {
connectedSites[origin + ":" + activeAddress] = true; await sessionStorageSet({ [origin + ":" + activeAddress]: true });
} }
return { result: [activeAddress] }; return { result: [activeAddress] };
} else { } else {
@@ -838,7 +880,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) ||
connectedSites[origin + ":" + activeAddress] (await isConnected(origin, activeAddress))
) { ) {
return { result: [activeAddress] }; return { result: [activeAddress] };
} }
@@ -874,7 +916,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) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -967,10 +1009,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 isConnected = const hasPermission =
allowed.includes(origin) || allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]; (await isConnected(origin, activeAddress));
if (!isConnected || !activeAddress) { if (!hasPermission || !activeAddress) {
return { result: [] }; return { result: [] };
} }
return { return {
@@ -992,12 +1034,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: { 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) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -1046,12 +1088,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: { 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) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -1093,12 +1135,13 @@ 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. The // A node that answered with an error is reported as itself, with
// wallet being unable to read its own profile is not that, and it // its own code and data. The wallet being unable to read its own
// must not be flattened into a message with no code: it goes back // profile is not that, and it must not be answered as an ordinary
// to the dispatcher, which has the one answer for it. // failure: it goes back to the dispatcher, which has the one
// answer for it.
if (e instanceof StateUnusableError) throw e; if (e instanceof StateUnusableError) throw e;
return { error: { message: e.message } }; return { error: requestError(e.message, e) };
} }
} }
@@ -1114,12 +1157,14 @@ 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) return { error: { message: "No accounts available" } }; if (!activeAddress) {
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) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -1173,7 +1218,7 @@ async function handleSendTransaction(params, origin) {
txParams, txParams,
); );
} catch (e) { } catch (e) {
return { error: { message: e.message } }; return { error: requestError(e.message, e) };
} }
// Population is a network round trip, and the user can switch address // Population is a network round trip, and the user can switch address
@@ -1183,10 +1228,9 @@ 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: { error: requestError(
message: "The active address changed while this transaction was being prepared, so it was not sent.",
"The active address changed while this transaction was being prepared, so it was not sent.", ),
},
}; };
} }
@@ -1232,10 +1276,6 @@ 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
@@ -1254,6 +1294,10 @@ async function broadcastAccountsChanged() {
} }
} }
resetPopupUrl(); 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] || [] : [];
@@ -1264,17 +1308,24 @@ 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) ||
connectedSites[origin + ":" + activeAddress]); (await isConnected(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(() => {});
} }
} }
@@ -1679,7 +1730,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (outcome.spendApproval) { if (outcome.spendApproval) {
settleApproval( settleApproval(
msg.id, msg.id,
{ error: { message: outcome.error } }, { error: requestError(outcome.error) },
{ holdsClaim: true }, { holdsClaim: true },
); );
} else { } else {
@@ -1706,7 +1757,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: { message: outcome.error } }, { error: requestError(outcome.error) },
{ holdsClaim: true }, { holdsClaim: true },
); );
sendResponse({ sendResponse({
@@ -1739,10 +1790,13 @@ 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: { message: outcome.error } }, { error: requestError(outcome.error, nodeError(e)) },
{ holdsClaim: true }, { holdsClaim: true },
); );
sendResponse({ sendResponse({
@@ -1864,7 +1918,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!retryable) { if (!retryable) {
settleApproval( settleApproval(
msg.id, msg.id,
{ error: { message: errMsg } }, { error: requestError(errMsg) },
{ holdsClaim: true }, { holdsClaim: true },
); );
} else { } else {
@@ -1892,41 +1946,57 @@ 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 : [];
for (const key of Object.keys(connectedSites)) { endConnections((key) => {
const address = key.slice(key.lastIndexOf(":") + 1); const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) { return removed.some((a) => sameAddress(a, address));
delete connectedSites[key]; }).catch((err) => {
} log.errorf(
} "ending the removed addresses' connections failed:",
err,
);
});
return false; return false;
} }
// Settings lists the sites connected without "Remember", which only this // Settings lists the sites connected without "Remember", which only
// worker knows. The origin is what precedes the key's last colon. // session storage holds.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") { if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse( sessionStorageGet(null)
Object.keys(connectedSites).map((key) => .then((items) =>
key.slice(0, key.lastIndexOf(":")), Object.keys(items).map((key) =>
), 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") {
for (const key of Object.keys(connectedSites)) { endConnections(
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) { (key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
delete connectedSites[key]; )
} .catch((err) => {
} log.errorf(
broadcastSiteRemoved(msg.origin); "ending the removed site's connections failed:",
return false; err,
);
})
.then(() => {
broadcastSiteRemoved(msg.origin);
sendResponse(null);
});
return true;
} }
// Settings switched the network and has saved it. Open tabs are told the // Settings switched the network and has saved it. Open tabs are told the
+18 -9
View File
@@ -49,14 +49,23 @@ 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") { if (msg.type !== "AUTISTMASK_EVENT") return;
window.postMessage( // The background chooses the addresses in accountsChanged for the site
{ // at the origin the event names, which is the origin of the tab's URL.
type: "AUTISTMASK_EVENT", // This page hears them only when its own origin is that one. A page whose
eventName: msg.eventName, // origin has no host is not a site: one its server sandboxes with a
data: msg.data, // Content-Security-Policy has the origin "null" whatever its URL, and is
}, // 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,
},
"*",
);
}); });
+20 -11
View File
@@ -634,17 +634,12 @@
</div> </div>
</div> </div>
<div id="confirm-warnings" class="mb-2 invisible"></div> <div id="confirm-warnings" class="mb-2 invisible"></div>
<div id="confirm-recipient-warning" class="mb-2 invisible"> <!-- These two sentences name the network's native token, so
<div show() in confirmTx.js sets them. -->
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" <div
> id="confirm-recipient-warning"
WARNING: The recipient address has ZERO transaction class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
history. This may indicate a fresh or unused address. ></div>
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"
@@ -669,6 +664,20 @@
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"
+17 -4
View File
@@ -64,8 +64,8 @@ window.addEventListener("pagehide", () => pageClosed.abort());
let refreshInFlight = false; let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the // The ten-second refresh startRefresh() starts, stopped when the popup moves to
// recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
let refreshTimer = null; let refreshTimer = null;
async function doRefreshAndRender() { async function doRefreshAndRender() {
@@ -99,9 +99,23 @@ 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,
pageClosed: pageClosed.signal, pageClosed: pageClosed.signal,
showAddWalletView: () => { showAddWalletView: () => {
pushCurrentView(); pushCurrentView();
@@ -318,8 +332,7 @@ async function init() {
} else { } else {
renderWalletList(); renderWalletList();
restoreView(); restoreView();
doRefreshAndRender(); startRefresh();
refreshTimer = setInterval(doRefreshAndRender, 10000);
} }
} }
+3 -3
View File
@@ -234,7 +234,7 @@ async function importMnemonic(ctx) {
showFlash("Ready.", 1000); showFlash("Ready.", 1000);
} }
ctx.doRefreshAndRender(); ctx.startRefresh();
} }
async function importPrivateKey(ctx) { async function importPrivateKey(ctx) {
@@ -274,7 +274,7 @@ async function importPrivateKey(ctx) {
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
ctx.doRefreshAndRender(); ctx.startRefresh();
} }
async function importXprvKey(ctx) { async function importXprvKey(ctx) {
@@ -368,7 +368,7 @@ async function importXprvKey(ctx) {
showFlash("Ready.", 1000); showFlash("Ready.", 1000);
} }
ctx.doRefreshAndRender(); ctx.startRefresh();
} }
function init(ctx) { function init(ctx) {
+166 -102
View File
@@ -2,7 +2,13 @@
// 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 { parseEther, parseUnits, formatEther, Contract } = require("ethers"); const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { const {
$, $,
showError, showError,
@@ -11,7 +17,6 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -20,6 +25,7 @@ const {
formatFee, formatFee,
} = 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");
@@ -32,11 +38,12 @@ 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 balance lines, the fee reserve and the insufficient-balance messages go // The balance line and the fee reserve go through it, as the approval screen's
// through it, as the approval screen's amounts do. // amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
@@ -60,6 +67,14 @@ 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.
// The popup can reopen onto this screen after the network was switched, so
// any balance Send knew may be another network's. 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;
@@ -87,7 +102,9 @@ function valueWithUsd(text, usdAmount) {
// once its amount is re-derived from the fee estimate. // 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 ? txInfo.tokenSymbol || "?" : nativeCurrency(); const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH"); const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
@@ -96,21 +113,56 @@ function renderAmount(txInfo) {
); );
} }
// 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 = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
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 network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The // Settings can switch the active network while the screen is open. This
// wait, success and error screens label the amount by it too. // screen and the wait, success and error screens label amounts by it.
txInfo.chainId = currentNetwork().chainId; txInfo.chainId = currentNetwork().chainId;
// That network's node, read here once and passed to every read below, so
// a read still in flight when the network is switched, and the fee
// estimate that follows the balance read, stay on it.
const provider = getProvider(state.rpcUrl, state.networkId);
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 ? txInfo.tokenSymbol || "?" : nativeCurrency(); const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol); const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
@@ -152,33 +204,7 @@ function show(txInfo) {
$("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, {
@@ -212,15 +238,22 @@ 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 " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " + " to pay the network fee for this transfer. Please add " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again."; " to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract. // Both shown later, by checkRecipientHistory(). The first sentence says
// 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 " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" 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.";
@@ -237,6 +270,20 @@ 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 // Reset password field and error
@@ -262,68 +309,77 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible"; $("confirm-burn-warning").style.visibility = "visible";
} }
estimateGas(txInfo); // The fee is estimated once the balances are read, because a max ETH
checkRecipientHistory(txInfo); // send's amount is the ETH balance minus the estimate's reserve.
readBalances(txInfo, provider).then(() => estimateGas(txInfo, provider));
checkRecipientHistory(txInfo, provider);
}
// Read the From address's balances from `provider`, the node of the network
// this screen is drawn 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, provider) {
let read;
try {
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() and again when the fee estimate resolves or fails. Every element it // show(), again when the balances are read, and again when the fee estimate
// touches already occupies its space, so re-running it never moves anything. // resolves or fails. Every element it touches already occupies its space, so
// 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
? displaySymbol(txInfo.tokenSymbol || "?") // Null while the balances are being read and after a read that failed.
: nativeCurrency(); // validateTransfer() takes a missing balance as nothing to spend from, so
// 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,
ethBalance: txInfo.balance, tokenDecimals: txInfo.tokenDecimals,
tokenBalance: txInfo.tokenBalance, ethBalance,
tokenBalance,
feeStatus, feeStatus,
feeWei, feeWei,
}); });
// Messages carrying the user's own numbers are built here; the fixed // An invalid amount is known before the first paint, so its message is
// sentences live in the reserved elements in index.html, except the // written here. Every message a later result brings has a reserved
// gas and fee-unknown ones, which show() sets. // element, its sentence fixed in index.html or set by show(), and is only
// 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) {
@@ -336,13 +392,20 @@ 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",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
); );
setVisible( setVisible(
"confirm-gas-error", "confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
); );
setVisible( setVisible(
"confirm-fee-unknown-error", "confirm-fee-unknown-error",
@@ -361,9 +424,10 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(id).style.visibility = visible ? "visible" : "hidden";
} }
async function estimateGas(txInfo) { // Estimate the network fee on `provider`, the node of the network this screen
// is drawn for.
async function estimateGas(txInfo, provider) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData(); const feeData = await provider.getFeeData();
let gasLimit; let gasLimit;
@@ -411,12 +475,13 @@ 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));
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
" " + " " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" reserved"; " reserved";
setVisible("confirm-fee-reserve", true); setVisible("confirm-fee-reserve", true);
} else { } else {
@@ -425,20 +490,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,
nativeCurrency(), nativeCurrencyByChainId(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 minus this estimate's reserve, not the // A max ETH send is the balance readBalances() read minus this
// Send screen's, and is signed with this estimate's fee fields: fees // estimate's reserve, not the Send screen's, and is signed with this
// fetched again at signing could exceed the reserve it leaves, and the // estimate's fee fields: fees fetched again at signing could exceed
// node would refuse it for want of funds. Where the balance no longer // the reserve it leaves, and the node would refuse it for want of
// covers the fee, the amount is left as it is and the balance check // funds. Where the balance is unknown or no longer covers the fee, the
// below says so. // amount is left as it is and the balance check below says so.
if (txInfo.max && txInfo.token === "ETH") { if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei); const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) { if (amount !== null) {
txInfo.amount = amount; txInfo.amount = amount;
renderAmount(txInfo); renderAmount(txInfo);
@@ -505,17 +570,16 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
} }
// Show a full-sentence send failure in the reserved errors box, the same // Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's // element and markup renderValidation() uses for the invalid-amount message,
// own numbers, so it never moves anything on the screen. // so it never moves anything on the screen.
function showSendError(message) { function showSendError(message) {
const el = $("confirm-errors"); const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`; el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible"; el.style.visibility = "visible";
} }
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo, provider) {
try { try {
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,
}); });
+34 -32
View File
@@ -24,6 +24,7 @@ const {
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { const {
feeReserveWei, feeReserveWei,
isValidAmount,
maxEthAmount, maxEthAmount,
maxTokenAmount, maxTokenAmount,
} = require("../../shared/txValidation"); } = require("../../shared/txValidation");
@@ -163,8 +164,9 @@ function renderSendTokenSelect(addr) {
} }
} }
// The token balance and scale the Send screen states and hands the // The token balance the Send screen states, and the scale it hands the
// confirmation screen, so the two screens describe the holding the same way. // confirmation screen, which reads the balance itself from the network it is
// 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
@@ -187,13 +189,11 @@ function renderSendTokenSelect(addr) {
// //
// 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. Stating it would leave validateTransfer() checking the amount // stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// against a number the wallet does not vouch for, so it is withdrawn: unknown // null rather than "0": this screen states it as unknown, as the confirmation
// scale means unknown balance. It is null rather than "0": both screens state // screen does, and Max fills nothing in for it. Only a stored quantity is
// an unknown balance as unknown, and validateTransfer() treats it as no balance // withdrawn: the "0" for a token that has no row at all is an absence of
// to spend from, which is the fail-closed side of an amount nobody can check. // holdings, which is true at every scale.
// 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(),
@@ -255,13 +255,18 @@ async function fillMaxAmount() {
const token = state.selectedToken || $("send-token").value; const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") { if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance; const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
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);
if (!(parseFloat(amount) > 0)) { // The balance is stored at the token's own decimal places and cut to
// 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;
} }
@@ -367,7 +372,24 @@ 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;
} }
@@ -391,33 +413,13 @@ 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,
}); });
+6 -4
View File
@@ -76,11 +76,13 @@ 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. // connections approved without "Remember" and tells its open tabs. The
// 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();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin }); await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists(); await renderSiteLists();
} }
@@ -230,8 +232,8 @@ async function renderSiteLists() {
Object.values(state.deniedSites).flat(), Object.values(state.deniedSites).flat(),
removeDeniedSite, removeDeniedSite,
); );
// Sites allowed without "Remember" are held only by the background, in // Sites allowed without "Remember" are held only in session storage, which
// memory, so it is asked for them. // the background keeps, 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" }),
+20 -10
View File
@@ -51,7 +51,8 @@ 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 and tx history. * Async — performs network calls to check contract status, tx count and
* 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.
@@ -78,21 +79,30 @@ async function getFullWarnings(address, provider, options = {}) {
log.errorf("contract check failed:", e.shortMessage || e.message); log.errorf("contract check failed:", e.shortMessage || e.message);
} }
// Skip tx count check for contracts — they may legitimately have // The transaction count is the number of transactions the address has
// zero inbound EOA transactions. // sent, so an address that has only ever received has a count of zero.
// 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) {
warnings.push({ const balance = await provider.getBalance(address);
type: "new-address", if (balance === 0n) {
message: warnings.push({
"This address has never sent a transaction. Double-check it is correct.", type: "new-address",
severity: "info", message:
}); "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("tx count check failed:", e.shortMessage || e.message); log.errorf(
"tx count or balance check failed:",
e.shortMessage || e.message,
);
} }
} }
+23 -3
View File
@@ -41,6 +41,7 @@ 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
@@ -72,15 +73,27 @@ 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) { constructor(message, code, data) {
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) { function fail(message, code, data) {
return new ApprovalPrepareError(message); return new ApprovalPrepareError(message, code, data);
} }
function present(v) { function present(v) {
@@ -134,6 +147,7 @@ function requestFrom(txParams, from) {
) { ) {
throw fail( throw fail(
"The site asked for two different gas limits for this transaction.", "The site asked for two different gas limits for this transaction.",
INVALID_PARAMS_CODE,
); );
} }
request.gasLimit = txParams.gas; request.gasLimit = txParams.gas;
@@ -144,6 +158,7 @@ function requestFrom(txParams, from) {
) { ) {
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;
@@ -228,6 +243,9 @@ async function prepareApprovalTx(provider, from, txParams) {
); );
} 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: " +
@@ -235,6 +253,8 @@ async function prepareApprovalTx(provider, from, txParams) {
e.message || e.message ||
"the network did not answer"), "the network did not answer"),
), ),
fromNode?.code,
fromNode?.data,
); );
} }
+11 -1
View File
@@ -746,6 +746,15 @@ 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
@@ -757,7 +766,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 = err.error || (err.info && err.info.error); const nested = nodeError(err);
if (nested && nested.message) texts.push(String(nested.message)); if (nested && nested.message) texts.push(String(nested.message));
return texts; return texts;
} }
@@ -891,6 +900,7 @@ module.exports = {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
nodeError,
isNonceCollision, isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
+56 -5
View File
@@ -174,9 +174,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(method) { function storageUnavailable(area, method) {
return Promise.reject( return Promise.reject(
new Error("extension storage.local is not available: " + method), new Error("extension storage." + area + " is not available: " + method),
); );
} }
@@ -187,7 +187,7 @@ function storageUnavailable(method) {
*/ */
function storageGet(keys) { function storageGet(keys) {
const storage = storageLocal(); const storage = storageLocal();
if (!storage) return storageUnavailable("get"); if (!storage) return storageUnavailable("local", "get");
return Promise.resolve(storage.get(keys)); return Promise.resolve(storage.get(keys));
} }
@@ -198,7 +198,7 @@ function storageGet(keys) {
*/ */
function storageSet(items) { function storageSet(items) {
const storage = storageLocal(); const storage = storageLocal();
if (!storage) return storageUnavailable("set"); if (!storage) return storageUnavailable("local", "set");
return Promise.resolve(storage.set(items)); return Promise.resolve(storage.set(items));
} }
@@ -215,7 +215,55 @@ function storageSet(items) {
*/ */
function storageRemove(keys) { function storageRemove(keys) {
const storage = storageLocal(); const storage = storageLocal();
if (!storage) return storageUnavailable("remove"); if (!storage) return storageUnavailable("local", "remove");
return Promise.resolve(storage.remove(keys));
}
// `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));
} }
@@ -307,6 +355,9 @@ module.exports = {
notify, notify,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
storageGet, storageGet,
storageLocal, storageLocal,
storageRemove, storageRemove,
+39 -13
View File
@@ -1,4 +1,5 @@
// Balance arithmetic for the Send and transaction confirmation screens. // The amount check and balance arithmetic for the Send and transaction
// 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
@@ -20,7 +21,7 @@ const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable"; const FEE_UNAVAILABLE = "unavailable";
const CODES = { const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on. // The amount is one isValidAmount() refuses.
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",
@@ -87,10 +88,31 @@ 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 the exact decimal string // exactly that reserve left behind. `ethBalance` is an exact decimal string as
// balances.js stores, never a rounded one. Null when the balance does not // formatEther() writes it, 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) {
@@ -112,9 +134,11 @@ 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, non-negative. Anything // amount — human decimal string being sent. One isValidAmount()
// else, a negative value included, is an unusable amount // refuses, 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
@@ -128,6 +152,7 @@ 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,
@@ -135,17 +160,17 @@ function validateTransfer({
} = {}) { } = {}) {
const codes = []; const codes = [];
const amountFp = toFixedPoint(amount); // An amount refused here never reaches the comparisons below: a negative
const ethFp = toFixedPoint(ethBalance) ?? 0n; // one would pass every one of them, then die at encode time in
// parseEther().
// A negative amount parses to a valid bigint, so every comparison below if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
// 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
@@ -195,6 +220,7 @@ module.exports = {
SCALE_DECIMALS, SCALE_DECIMALS,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
isValidAmount,
maxEthAmount, maxEthAmount,
maxTokenAmount, maxTokenAmount,
toFixedPoint, toFixedPoint,
+222 -131
View File
@@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08; const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09; const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b; const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e; const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -318,10 +320,133 @@ function decodeExactly(types, data) {
return decoded; return decoded;
} }
// Decode one V4 swap action's parameters into the currency it spends
// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action,
// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts.
// A field the action does not state is null. Throws when the parameters are
// not in the deployed shape (see decodeExactly()).
function decodeV4SwapAction(actionId, param) {
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
const path = s[0][1];
return {
tokenIn: s[0][0],
tokenOut: path.length > 0 ? path[path.length - 1][0] : null,
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_IN_SINGLE) {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_OUT) {
// ExactOutputParams: (address currencyOut,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountOut, uint128 amountInMaximum)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
// The router works through the path from its last entry back to its
// first, starting from currencyOut, so the first entry is what the
// action spends.
const path = s[0][1];
return {
tokenIn: path.length > 0 ? path[0][0] : null,
tokenOut: s[0][0],
amountIn: null,
amountOutMin: null,
};
}
// V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above,
// bool zeroForOne, uint128 amountOut, uint128 amountInMaximum,
// bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: null,
amountOutMin: null,
};
}
// The currencies in `list` that `other` does not hold, each listed once.
function currenciesNotIn(list, other) {
return [...new Set(list.filter((c) => !other.includes(c)))];
}
// The amount that the swap actions on one side of a V4 step state together:
// the one action's own amount, or the sum when there are several and each
// states a quantity. Exact-out actions state none here, and the open delta is
// not a quantity. Otherwise null.
function totalAmount(amounts) {
if (amounts.length === 1) return amounts[0];
if (amounts.length === 0) return null;
if (!amounts.every((a) => typeof a === "bigint")) return null;
return amounts.reduce((sum, a) => sum + a, 0n);
}
// Decode V4_SWAP (command 0x10) input bytes. // Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params). // The input is ABI-encoded as (bytes actions, bytes[] params). Each swap
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, // action gives the currency it spends and the currency it buys (see
// and swap amounts from the swap sub-actions. // decodeV4SwapAction()). The step's sides then follow three rules:
//
// 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one
// currency that some swap action spends and none buys. Token Out is the
// currency TAKE or TAKE_ALL names; otherwise the one currency that some
// swap action buys and none spends. If there is not exactly one such
// currency, that side is unknown.
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
//
// A currency a swap action does not name, such as what an exact-in action
// with an empty path buys, is null and counts in rules 1 and 2 as a currency
// like any other: a step of only that action has an unknown Token Out and
// the action's amountOutMin as Min. received.
//
// `hasSwapAction` is true when the step has a swap action, read or not.
// decode() then always sets the output side from this step, and sets the
// input side from it only when no earlier step set it. A side it sets that
// these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
function decodeV4Swap(input) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -330,11 +455,12 @@ function decodeV4Swap(input) {
let settleToken = null; let settleToken = null;
let takeToken = null; let takeToken = null;
let amountIn = null; // The step's swap actions that could be read, as decodeV4SwapAction()
let amountOutMin = null; // reads them.
const swaps = [];
// Set when a swap action's parameters are not in the deployed shape // Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE // (see decodeExactly()), so nothing was read from that action. SETTLE,
// and TAKE may still name both currencies. // SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies.
let swapUndecoded = false; let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) { for (let i = 0; i < actions.length; i++) {
@@ -347,6 +473,10 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
settleToken = s[0]; settleToken = s[0];
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
} else if (actionId === V4_TAKE) { } else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount) // TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode( const t = coder.decode(
@@ -354,107 +484,20 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
takeToken = t[0]; takeToken = t[0];
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
} else if ( } else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) { ) {
// Extract amounts from exact-in swap actions try {
if (actionId === V4_SWAP_EXACT_IN) { swaps.push(decodeV4SwapAction(actionId, params[i]));
// ExactInputParams: (address currencyIn, } catch {
// tuple(address,uint24,int24,address,bytes)[] path, swapUndecoded = true;
// uint128 amountIn, uint128 amountOutMin)
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
swapUndecoded = true;
}
} else {
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
swapUndecoded = true;
}
} }
} }
} catch { } catch {
@@ -462,12 +505,54 @@ function decodeV4Swap(input) {
} }
} }
// Rule 3.
if (swapUndecoded) {
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
swapUndecoded,
hasSwapAction: true,
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
let tokenIn = null;
let amountIn = null;
const spentOnly = currenciesNotIn(spent, bought);
if (present(settleToken) || spentOnly.length === 1) {
tokenIn = present(settleToken) ? settleToken : spentOnly[0];
amountIn = totalAmount(
swaps
.filter((s) => s.tokenIn === tokenIn)
.map((s) => s.amountIn),
);
}
// Rules 1 and 2 for Token Out.
let tokenOut = null;
let amountOutMin = null;
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
return { return {
tokenIn: settleToken, tokenIn,
tokenOut: takeToken, tokenOut,
amountIn, amountIn,
amountOutMin, amountOutMin,
swapUndecoded, swapUndecoded,
hasSwapAction: swaps.length > 0,
}; };
} catch { } catch {
return null; return null;
@@ -491,6 +576,7 @@ function decode(data, toAddress, sources) {
let inputEstablished = false; let inputEstablished = false;
let outputToken = null; let outputToken = null;
let minOutput = null; let minOutput = null;
let outputEstablished = false;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
let hasV2ExactOut = false; let hasV2ExactOut = false;
let swapUndecoded = false; let swapUndecoded = false;
@@ -510,8 +596,11 @@ function decode(data, toAddress, sources) {
// //
// The input side is fixed by the first hop that states either half, the // The input side is fixed by the first hop that states either half, the
// output side by the last, because the final leg is what the user // output side by the last, because the final leg is what the user
// receives. A half the establishing hop did not state stays null and // receives. A V4 step with a swap action sets a side even when it
// the line says so, rather than being filled in from a different hop. // states neither half: the output side always, and the input side
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop.
const setInput = (token, amount) => { const setInput = (token, amount) => {
inputToken = present(token) ? token : null; inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null; inputAmount = present(amount) ? amount : null;
@@ -526,6 +615,7 @@ function decode(data, toAddress, sources) {
if (!present(token) && !present(amount)) return; if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null; outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null; minOutput = present(amount) ? amount : null;
outputEstablished = true;
}; };
for (let i = 0; i < commandsBytes.length; i++) { for (let i = 0; i < commandsBytes.length; i++) {
@@ -597,30 +687,33 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) { if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) { if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies // A swap action of this step went unread, so the step
// and amounts, so the step is marked and still sets // is marked.
// both sides as a swap step does. A side its SETTLE or
// TAKE did not name is set undetermined, not left to
// an earlier or later step's token and figure.
swapUndecoded = true; swapUndecoded = true;
commandNames[i] += " (could not be decoded)"; commandNames[i] += " (could not be decoded)";
}
if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output
// side, as the last swap step wins, and sets the
// input side only when no earlier step set it. Each
// side it sets is set as a pair, even when the step
// states neither half, so a side decodeV4Swap() left
// unknown is set unknown with no figure. A token
// stated without a figure, or a figure without a
// token, likewise never sits beside an earlier step's
// other half.
if (!inputEstablished) { if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn); setInput(v4.tokenIn, v4.amountIn);
} }
outputToken = v4.tokenOut; outputToken = v4.tokenOut;
minOutput = v4.amountOutMin; minOutput = v4.amountOutMin;
outputEstablished = true;
} else if (v4) { } else if (v4) {
// A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn); setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no
// output currency makes the output *undetermined* — it
// is neither ETH nor whatever an earlier step named,
// and that figure is no longer counted in that token.
// Equally, a step that names an output currency but no
// minimum leaves Min. received unstated rather than
// keeping an earlier step's figure beside the new
// token. setOutput() is both rules; a step that states
// neither half leaves the output alone.
setOutput(v4.tokenOut, v4.amountOutMin); setOutput(v4.tokenOut, v4.amountOutMin);
} }
} }
@@ -634,21 +727,19 @@ function decode(data, toAddress, sources) {
} }
// Resolve token info. A null token on either side means no step named // Resolve token info. A null token on either side means no step named
// a currency for it, or a V4 step whose swap action went unread left // a currency for it, or a V4 step with a swap action left it
// it undetermined; tokenInfo() refuses rather than calling it ETH. // unknown; tokenInfo() refuses rather than calling it ETH.
// UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the // UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// output side is WETH, or when no step set the output side. A step // output side is WETH, or when no step set the output side. A V4 step
// whose swap action went unread did set it, to undetermined. Any other // with a swap action did set it, even when it set it unknown. Any other
// output keeps its own token and figure: a swap that buys USDC and // output keeps its own token and figure: a swap that buys USDC and
// then unwraps the WETH it did not spend receives USDC. // then unwraps the WETH it did not spend receives USDC.
const outputIsWeth = const outputIsWeth =
present(outputToken) && present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase()); WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset =
!present(outputToken) && !present(minOutput) && !swapUndecoded;
const inInfo = tokenInfo(inputToken, sources); const inInfo = tokenInfo(inputToken, sources);
const outInfo = const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset) hasUnwrapWeth && (outputIsWeth || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null } ? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources); : tokenInfo(outputToken, sources);
+1 -1
View File
@@ -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 memory. // "Remember", which only the background holds, in session storage.
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];
+167
View File
@@ -0,0 +1,167 @@
// 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 -2
View File
@@ -230,7 +230,7 @@ beforeEach(() => {
addWallet.init({ addWallet.init({
renderWalletList: () => {}, renderWalletList: () => {},
doRefreshAndRender: () => {}, startRefresh: () => {},
pageClosed: new AbortController().signal, pageClosed: new AbortController().signal,
}); });
confirmTx.init({}); confirmTx.init({});
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT, to: RECIPIENT,
amount: "0.5", amount: "0.5",
token: "ETH", token: "ETH",
balance: "1.0",
}); });
await settle(); await settle();
node("confirm-tx-password").value = PASSWORD; node("confirm-tx-password").value = PASSWORD;
+1 -1
View File
@@ -94,7 +94,7 @@ describe.each([
async function create(pageClosed) { async function create(pageClosed) {
addWallet.init({ addWallet.init({
renderWalletList: () => {}, renderWalletList: () => {},
doRefreshAndRender: () => {}, startRefresh: () => {},
pageClosed, pageClosed,
}); });
element("tab-" + mode).listeners.click(); element("tab-" + mode).listeners.click();
+39
View File
@@ -0,0 +1,39 @@
// 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"]);
});
+33 -12
View File
@@ -321,9 +321,10 @@ 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. // handler passed to sendResponse. `onResponse`, when given, receives the
function send(msg, sender) { // answer when the handler sends it, as the sender's callback does.
const sendResponse = jest.fn(); function send(msg, sender, onResponse) {
const sendResponse = jest.fn(onResponse);
const kept = messageListener(msg, sender || {}, sendResponse); const kept = messageListener(msg, sender || {}, sendResponse);
return { sendResponse, kept }; return { sendResponse, kept };
} }
@@ -1161,9 +1162,12 @@ 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(
@@ -1182,8 +1186,10 @@ 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/,
), ),
@@ -1233,6 +1239,7 @@ 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/),
}, },
}); });
@@ -1323,6 +1330,7 @@ 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/),
}, },
}); });
@@ -1382,7 +1390,10 @@ 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: { message: expect.stringMatching(/active address changed/) }, error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
}); });
}); });
@@ -1526,7 +1537,10 @@ describe("what the approval is verified against", () => {
}), }),
); );
expect(pending.result()).toEqual({ expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) }, error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
}); });
}); });
@@ -1576,7 +1590,9 @@ describe("what the approval is verified against", () => {
error: refusal, error: refusal,
retryable: false, retryable: false,
}); });
expect(pending.result()).toEqual({ error: { message: refusal } }); expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
}); });
// ethers signs a `bool` field sent as the string "false" as true, and one // ethers signs a `bool` field sent as the string "false" as true, and one
@@ -1611,7 +1627,9 @@ describe("what the approval is verified against", () => {
error: refusal, error: refusal,
retryable: false, retryable: false,
}); });
expect(pending.result()).toEqual({ error: { message: refusal } }); expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
}, },
); );
@@ -1649,6 +1667,7 @@ 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/,
), ),
@@ -1671,8 +1690,11 @@ 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/,
), ),
@@ -2594,9 +2616,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 memory, // A site connected without "Remember" is held only in the background's session
// keyed to the address it was connected to. Removing that address, or the // storage, keyed to the address it was connected to. Removing that address, or
// wallet holding it, must end the connection as part of the removal itself. // the 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.
// //
@@ -2775,8 +2797,7 @@ 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) => {
const { sendResponse } = bg.send(msg, bg.fromPopup); bg.send(msg, bg.fromPopup, callback);
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();
+259 -11
View File
@@ -7,17 +7,35 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in // reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between. // Settings in between.
// //
// Its balance line and balance check are that network's too
// (https://git.eeqj.de/sneak/AutistMask/issues/558). The popup can reopen onto
// a confirmation screen saved before the network was switched in another
// popup, and the screen, drawn again for the new network, showed and checked
// the balance Send had read on the old one. The balance read and the fee
// estimate after it stay on that network when it is switched while they run.
//
// Driven through the real confirmation screen, signer and chain switch, with // Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its // only the node 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. // 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"; "use strict";
// The signed transactions the stub node was handed, with its network's chain id. // The balances the stub node reports on each network, by network name: ETH
const mockNode = { broadcast: [] }; // in wei and the token in its base units. The networks whose node was asked
// for the fee, in order. And the signed transactions it was handed, with its
// network's chain id.
const mockNode = {
balanceWei: {},
tokenUnits: {},
feeAskedOn: [],
broadcast: [],
};
jest.mock("ethers", () => { jest.mock("ethers", () => {
const actual = jest.requireActual("ethers"); const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider { class StubProvider {
constructor(url, network) { constructor(url, network) {
this.network = network; this.network = network;
@@ -25,7 +43,22 @@ jest.mock("ethers", () => {
async getNetwork() { async getNetwork() {
return this.network; return this.network;
} }
async 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()");
}
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() { async getFeeData() {
mockNode.feeAskedOn.push(this.network.name);
return { return {
maxFeePerGas: 20000000000n, maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n,
@@ -123,7 +156,13 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers"); const {
BaseWallet,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
} = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault"); const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state"); const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch"); const { onChainSwitch } = require("../src/shared/chainSwitch");
@@ -135,6 +174,8 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address; const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
// A token with 6 decimal places, which the bundled list does not know.
const TOKEN = "0x" + "d".repeat(40);
function el(id) { function el(id) {
return global.document.getElementById(id); return global.document.getElementById(id);
@@ -150,6 +191,12 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
elements.clear(); elements.clear();
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.feeAskedOn = [];
mockNode.broadcast = []; mockNode.broadcast = [];
txStatus.showWait.mockClear(); txStatus.showWait.mockClear();
txStatus.showError.mockClear(); txStatus.showError.mockClear();
@@ -160,16 +207,23 @@ afterEach(() => {
signTransaction.mockRestore(); signTransaction.mockRestore();
}); });
// Show the confirmation screen for 1 of the native token on `networkId`, as // show() reads the balances and estimates the fee without awaiting either;
// Review on the Send screen does, with its fee estimate settled. // this lets both settle.
async function review(networkId) { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for what `send` describes instead, as Review on the Send screen does, with
// its balance read and its fee estimate settled.
async function review(networkId, send = {}) {
await onChainSwitch(networkId); await onChainSwitch(networkId);
state.wallets = [ state.wallets = [
{ {
type: "key", type: "key",
name: "Wallet 1", name: "Wallet 1",
encryptedSecret, encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }], addresses: [{ address: HOLDER }],
}, },
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -181,13 +235,36 @@ async function review(networkId) {
ensName: null, ensName: null,
amount: "1", amount: "1",
token: "ETH", token: "ETH",
balance: "5.0",
tokenSymbol: null, tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null, tokenDecimals: null,
max: false, max: false,
...send,
}); });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); await settle();
}
// Switch the network to `networkId` as Settings in another popup does, then
// open the popup again onto the confirmation screen it saved.
async function reopenOn(networkId) {
const saved = JSON.parse(JSON.stringify(state.viewData));
await onChainSwitch(networkId);
elements.clear();
state.viewData = saved;
confirmTx.init({});
confirmTx.restore();
await settle();
}
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() { async function signAndSend() {
@@ -257,3 +334,174 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
}); });
describe("a confirmation screen saved on mainnet and reopened after a switch to Sepolia", () => {
test("states and checks the Sepolia balance, which covers the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("0.5"),
sepolia: parseEther("5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("0.5000 ETH");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-type").textContent).toBe(
"Native SepoliaETH transfer",
);
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia balance, which does not cover the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("0.5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(sendDisabled()).toBe(false);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("states and checks the Sepolia token balance, which covers the send", async () => {
mockNode.tokenUnits = { mainnet: 0n, sepolia: 10000000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("0.0000 TOK");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-gas-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia token balance, which does not cover the send", async () => {
mockNode.tokenUnits = { mainnet: 10000000n, sepolia: 500000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(sendDisabled()).toBe(false);
await reopenOn("sepolia");
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);
});
});
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, so a node chosen then would
// be the new network's.
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();
}
expect(mockNode.feeAskedOn).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(mockNode.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);
});
});
+354
View File
@@ -0,0 +1,354 @@
// 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],
});
});
});
+23 -9
View File
@@ -244,16 +244,21 @@ function latestBlock() {
}; };
} }
// keccak("decimals()")[0:4], and the same for symbol() and name(). // keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41"; const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03"; const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol() // Every eth_call still answers with a zero word except decimals(), symbol(),
// and name() on the stub token. The wallet reads decimals() back at signing // name() and balanceOf() on the stub token. The wallet reads decimals() back
// time to compare with the scale the confirmation screen rendered (issue // at signing time to compare with the scale the confirmation screen rendered
// #305). Adding the token by its contract address reads all three (issue // (issue #305). Adding the token by its contract address reads the first three
// #295); symbol() and name() answer what the explorer reports for it. // (issue #295); symbol() and name() answer what the explorer reports for it.
// The confirmation screen reads balanceOf()
// (https://git.eeqj.de/sneak/AutistMask/issues/558), which answers the holding
// the explorer reports, or zero when no holding is seeded.
// //
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
@@ -278,6 +283,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_NAME)) { if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]); return abi.encode(["string"], [tokenObject(opts).name]);
} }
if (data.startsWith(SELECTOR_BALANCE_OF) && opts.seedTokenBalance) {
return word(tokenBalanceItems(opts)[0].value);
}
return ZERO_WORD; return ZERO_WORD;
} }
@@ -420,9 +428,10 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false. // Hold a reply open for as long as the test asks: until opts[name] is false.
// //
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read // The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// here rather than captured, so a test flips it on the same options object the // holdBlockscout) is read here rather than captured, so a test flips it on the
// route was registered with — the same pattern as seedTokenTransfer. This is // same options object the route was registered with — the same pattern as
// seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight; // the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would // sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine. // assert nothing on a slow machine.
@@ -557,6 +566,9 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url()); report("unstubbed request: POST " + route.request().url());
return route.abort(); return route.abort();
} }
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) { if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report); await awaitRelease(opts, "holdGasEstimate", report);
} }
@@ -615,6 +627,8 @@ function traceEnabled(raw) {
* fixture expects. * fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
* eth_getBalance until this is cleared again.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an * @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an * @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+94 -39
View File
@@ -2232,7 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in // What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the // one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is. // fee block says, how tall the whole view is, and where Sign & Send sits in it.
async function confirmState(page) { async function confirmState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@@ -2245,6 +2245,9 @@ async function confirmState(page) {
}; };
return { return {
height: el("view-confirm-tx").getBoundingClientRect().height, height: el("view-confirm-tx").getBoundingClientRect().height,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
type: el("confirm-type").textContent.trim(), type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(), balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(), fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,6 +2256,9 @@ async function confirmState(page) {
errors: shown("confirm-errors") errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim() ? el("confirm-errors").textContent.trim()
: "", : "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"), amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"), gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"), feeUnknownError: shown("confirm-fee-unknown-error"),
@@ -2271,6 +2277,16 @@ async function waitForEstimate(page) {
); );
} }
async function waitForBalance(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) { async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) { if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back"); await page.click("#btn-confirm-back");
@@ -2282,13 +2298,14 @@ async function backToAddress(page) {
await openAddressDetail(page); await openAddressDetail(page);
} }
// Drive the popup to the confirmation screen for one send. // Drive the popup to the confirmation screen for one send, and return as soon
// as the screen is shown, its balance read still in flight.
// //
// It waits for the send screen to be showing `balance`, the fixture's balance // It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it — // as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every // rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change. // assertion below deterministic after a fixture change.
async function goToConfirm(page, { token, balance, amount }) { async function openConfirm(page, { token, balance, amount }) {
await backToAddress(page); await backToAddress(page);
await page.click("#btn-send"); await page.click("#btn-send");
await visible(page, "#view-send"); await visible(page, "#view-send");
@@ -2303,6 +2320,14 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount); await page.fill("#send-amount", amount);
await page.click("#btn-send-review"); await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx"); await visible(page, "#view-confirm-tx");
}
// The same, returning once the screen has read the balance. It reads it
// before it estimates the fee, so every state below is read with the balance
// in place, the pending fee included.
async function goToConfirm(page, send) {
await openConfirm(page, send);
await waitForBalance(page);
await assertAddressesFit(page, "the confirmation screen"); await assertAddressesFit(page, "the confirmation screen");
} }
@@ -2378,7 +2403,10 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
"the estimate-failed message is shown for an estimate that is merely pending", "the estimate-failed message is shown for an estimate that is merely pending",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown before the fee is known", "a balance message is shown before the fee is known",
); );
}); });
@@ -2409,6 +2437,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
); );
assert( assert(
st.errors === "" && st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError && !st.amountFeeError &&
!st.gasError && !st.gasError &&
!st.feeUnknownError, !st.feeUnknownError,
@@ -2475,30 +2504,54 @@ test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", as
); );
}); });
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => { // The screen is first measured with its balance read held, so the message the
await goToConfirm(env.page, { // read brings must already hold its space when the read returns (#558).
token: "ETH", test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
balance: FUNDED_ETH_TEXT + " ETH", env.routeOpts.holdBalance = true;
amount: OVER_BALANCE_AMOUNT, let reading;
}); try {
await openConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
reading = await confirmState(env.page);
} finally {
env.routeOpts.holdBalance = false;
}
assert(
reading.balance === "Loading...",
"the balance read was not held: " + JSON.stringify(reading.balance),
);
await waitForBalance(env.page);
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert( assert(
st.errors === want, st.balance === FUNDED_ETH_TEXT + " ETH",
"wrong over-balance message: " + JSON.stringify(st.errors), "the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
const want =
"Your ETH balance does not cover this amount. Please go back and" +
" send a smaller amount.";
assert(
st.balanceError === want,
"wrong over-balance message: " + JSON.stringify(st.balanceError),
); );
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance"); assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert( assert(
!st.amountFeeError, !st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance", "the amount-plus-fee message is shown for an amount that alone exceeds the balance",
); );
assert(
st.sendTop === reading.sendTop,
"Sign & Send moved when the balance read returned: " +
reading.sendTop +
"px -> " +
st.sendTop +
"px",
);
}); });
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => { test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2542,7 +2595,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
"the estimate-failed message is not shown after a failed estimate", "the estimate-failed message is not shown after a failed estimate",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown for an estimate that simply failed", "a balance message is shown for an estimate that simply failed",
); );
assert( assert(
@@ -2633,21 +2689,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert( assert(
st.errors === want, st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"wrong over-token-balance message: " + JSON.stringify(st.errors), "the confirmation screen shows the wrong token balance: " +
JSON.stringify(st.balance),
);
const want =
"Your " +
STUB_TOKEN.symbol +
" balance does not cover this amount. Please go back and send a" +
" smaller amount.";
assert(
st.balanceError === want,
"wrong over-token-balance message: " + JSON.stringify(st.balanceError),
); );
assert( assert(
st.sendDisabled, st.sendDisabled,
@@ -2703,9 +2757,9 @@ test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#2
"the native-ETH over-budget message is shown on an ERC-20 transfer", "the native-ETH over-budget message is shown on an ERC-20 transfer",
); );
assert( assert(
st.errors === "", st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " + "a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors), JSON.stringify(st.errors + st.balanceError),
); );
assert( assert(
st.height === env.erc20PendingHeight, st.height === env.erc20PendingHeight,
@@ -3861,10 +3915,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// all; this measures that the whole address is visible (#517). // all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt"); await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3 // Remembered, because the sign and transaction tests below all
// terminates after 30 seconds idle. The in-memory connectedSites map // require the origin to still be authorized.
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
await popup.check("#approve-remember"); await popup.check("#approve-remember");
await clickAndClose(popup, "#btn-approve"); await clickAndClose(popup, "#btn-approve");
@@ -4861,6 +4913,9 @@ async function main() {
seedTokenBalance: false, seedTokenBalance: false,
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,
// Whether a JSON-RPC batch asking for a balance is held unanswered:
// the confirmation screen's own balance read (#558).
holdBalance: false,
holdGasEstimate: false, holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held // Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475). // unanswered: the address scan after creating a wallet (#475).
+22 -3
View File
@@ -22,6 +22,11 @@
// Before the approvals are raised it watches the worker stop on its own, so a // Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass. // run in which something else keeps the worker running cannot pass.
// //
// The site is connected without "Remember", and once the worker has stopped
// the program requires the site still to be told the address: such a
// connection used to be held in the worker's memory and ended with it
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// The dApp page and the stub node are the loopback ones the Firefox suite also // The dApp page and the stub node are the loopback ones the Firefox suite also
// uses (tests/e2e/dapp.js). The site is connected and the node chosen // uses (tests/e2e/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage // through the extension's own screens, so nothing is written to its storage
@@ -311,8 +316,8 @@ async function main() {
); );
await send("Target.closeTarget", { targetId: popup.targetId }); await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so // The site, connected through the real prompt without "Remember", so
// the connection is stored rather than held by the worker. // the connection is one that has to outlive the worker stopping below.
const dapp = await openPage(node.url); const dapp = await openPage(node.url);
await until("the injected provider", () => await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"), dapp.evaluate("!!window.ethereum && !!window.__dapp"),
@@ -329,7 +334,7 @@ async function main() {
inPrompt(shows("view-approve-site")), inPrompt(shows("view-approve-site")),
); );
await inPrompt( await inPrompt(
"document.getElementById('approve-remember').checked = true", "document.getElementById('approve-remember').checked = false",
); );
// The prompt closes itself on the click. // The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {}); await inPrompt(click("btn-approve")).catch(() => {});
@@ -365,6 +370,20 @@ async function main() {
"s", "s",
); );
// The site's request starts the worker again, and the new worker still
// has the connection allowed without "Remember"
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
const stillConnected = await dapp.evaluate(
"window.ethereum.request({ method: 'eth_accounts' })",
);
check(
"a site allowed without Remember is still connected after the worker stopped",
Array.isArray(stillConnected) &&
stillConnected.length === 1 &&
getAddress(stillConnected[0]) === address,
"eth_accounts answered " + JSON.stringify(stillConnected),
);
// A transaction approval and a signature approval, both open at once. // A transaction approval and a signature approval, both open at once.
await dapp.evaluate( await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" + "window.__dapp.start('tx', 'eth_sendTransaction', [" +
+135
View File
@@ -0,0 +1,135 @@
// A popup that opened with no wallet never started the ten-second refresh, so
// after the first wallet was added Home went on showing the balance it had then
// until the popup was reopened
// (https://git.eeqj.de/sneak/AutistMask/issues/530). Each test boots the real
// popup, adds wallets through AddWallet, and runs the intervals the popup set
// with tick(). The balance stand-in writes what the chain holds onto every
// address, as the real refreshBalances() does.
const { HDNodeWallet, Mnemonic } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PRIVATE_KEY = "0x" + "11".repeat(32);
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const TABS = [
{ tab: "tab-mnemonic", field: "wallet-mnemonic", secret: PHRASE },
{ tab: "tab-privkey", field: "import-private-key", secret: PRIVATE_KEY },
{ tab: "tab-xprv", field: "import-xprv-key", secret: XPRV },
];
// A record this build cannot read: its wallets are a string.
const UNREADABLE = {
hasWallet: true,
wallets: ADDRESS,
activeAddress: ADDRESS,
};
// The ETH balance the chain holds for every address, in ether.
let chainBalance = "0.0";
// Boots the popup over `stored`, with the balance stand-in reading the chain.
async function boot(stored) {
const page = await bootPopup(stored);
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementation(async (wallets) => {
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance;
}
});
return { page, refreshBalances };
}
// Fills in the open AddWallet screen and presses "Import".
async function importOn(page, { tab, field, secret }) {
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
beforeEach(() => {
chainBalance = "0.0";
});
afterEach(() => {
cleanupPopup();
});
describe("a popup that opened on Welcome", () => {
test.each(TABS)(
"after the first wallet is added from $tab, funds that arrive show on Home",
async (entry) => {
const { page } = await boot(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 ETH");
chainBalance = "2.0";
await page.tick();
expect(page.text("total-value")).toBe("2.0000 ETH");
},
);
test("a second wallet starts no second refresh", async () => {
const { page, refreshBalances } = await boot(undefined);
await page.click("btn-welcome-add");
await importOn(page, TABS[1]);
await page.click("btn-add-wallet-bottom");
await importOn(page, TABS[0]);
const calls = refreshBalances.mock.calls.length;
await page.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
test("a wallet added as the recovery screen goes up starts no refresh", async () => {
const { page, refreshBalances } = await boot(undefined);
// The stored record becomes unreadable during the address scan that
// follows the import, and a save finds it.
const { scanForAddresses } = require("../src/shared/balances");
const { saveState } = require("../src/shared/state");
scanForAddresses.mockImplementationOnce(async () => {
page.storage.write("autistmask", UNREADABLE);
await saveState().catch(() => {});
return { addresses: [], nextIndex: 1 };
});
await page.click("btn-welcome-add");
await importOn(page, TABS[0]);
expect(page.visibleViews()).toEqual(["state-recovery"]);
await page.tick();
expect(refreshBalances).not.toHaveBeenCalled();
});
});
describe("a popup that opened with a wallet", () => {
test("adding a wallet starts no second refresh", async () => {
const { page, refreshBalances } = await boot(unversionedValidProfile());
await page.click("btn-add-wallet-bottom");
await importOn(page, TABS[1]);
const calls = refreshBalances.mock.calls.length;
await page.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
});
+15 -7
View File
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
// The 1.5 of the native token the address holds on either network.
async getBalance() {
return 1500000000000000000n;
}
// 10 gwei expected, 20 gwei reserved per gas. // 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() { async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n }; return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -267,6 +271,13 @@ describe.each([
); );
}); });
test("the warning for a recipient that has never sent a transaction", async () => {
await confirmSend("0.1");
expect(text("confirm-recipient-warning")).toContain(
"has never sent a transaction and holds no " + symbol + ".",
);
});
// A token reports whatever symbol it likes. One reporting the label the // A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is // wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was. // a fake, exactly as one reporting `ETH` always was.
@@ -332,14 +343,11 @@ describe.each([
test("the insufficient-balance error", async () => { test("the insufficient-balance error", async () => {
await confirmSend("2"); await confirmSend("2");
expect( expect(text("confirm-balance-error")).toBe(
global.document.getElementById("confirm-errors").innerHTML, "Your " +
).toContain(
"You have 1.5000 " +
symbol + symbol +
" but are trying to send 2 " + " balance does not cover this amount. Please go back and send" +
symbol + " a smaller amount.",
".",
); );
}); });
}); });
-1
View File
@@ -767,7 +767,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
amount: "1", amount: "1",
balance: "2",
}, },
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
+42
View File
@@ -0,0 +1,42 @@
// A timer the popup started and that fires after cleanupPopup() has removed
// the page throws "document is not defined", and the throw fails whatever test
// the jest worker is running by then, in this file or another
// (https://git.eeqj.de/sneak/AutistMask/issues/578). The flash message's
// timer is the one that did it. A throw from a timer during a test fails that
// test, so each test here passes only if nothing throws.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const FLASH_MS = 10;
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
afterEach(() => {
cleanupPopup();
});
test("a flash message clears while the page is up", async () => {
const page = await bootPopup(unversionedValidProfile());
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Copied.", FLASH_MS);
expect(page.text("flash-msg")).toBe("Copied.");
await wait(FLASH_MS * 5);
expect(page.text("flash-msg")).toBe("");
});
test("a flash message's timer does not fire after cleanupPopup()", async () => {
await bootPopup(unversionedValidProfile());
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Copied.", FLASH_MS);
cleanupPopup();
await wait(FLASH_MS * 5);
});
+282
View File
@@ -0,0 +1,282 @@
// What a page is told when a request fails
// (https://git.eeqj.de/sneak/AutistMask/issues/533).
//
// EIP-1193 gives every rejected request a numeric `code`, and lets it carry
// `data`. A node's error used to reach the page as its message alone: a
// reverting eth_call lost code 3 and the revert data a site needs to decode
// the revert, and a site could not tell that failure from any other.
//
// Each request here goes from a page through the real background to a fake
// node, and what is checked is the answer the background gives the page. The
// content script relays that answer as it is, and src/content/inpage.js turns
// its code and data into the error the page catches (tests/inpageErrors.test.js).
//
// The background reaches a node two ways, and the fake node answers both:
// fetch(), which the read-only methods are proxied through, and ethers' own
// HTTP transport, which a transaction is prepared through.
const { AbiCoder } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ORIGIN = "https://dapp.example";
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TOKEN = "0x1111111111111111111111111111111111111111";
const SEPOLIA = networkById("sepolia");
// Error("bal"), which a token whose transfer() does require(..., "bal")
// reverts with when the balance is too low.
const REVERT_DATA =
"0x08c379a0" +
AbiCoder.defaultAbiCoder().encode(["string"], ["bal"]).slice(2);
// What a node answers for a call that reverts, and for an address that is not
// one.
const REVERTED = {
code: 3,
message: "execution reverted: bal",
data: REVERT_DATA,
};
const INVALID_ADDRESS = { code: -32602, message: "invalid string length" };
const WITH_ADDRESS = {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{ address: ADDRESS, balance: "0.0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "sepolia",
rpcUrl: SEPOLIA.defaultRpcUrl,
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [ORIGIN] },
deniedSites: {},
trackedTokens: [],
};
const NO_ADDRESS = {
...WITH_ADDRESS,
hasWallet: false,
wallets: [],
activeAddress: null,
allowedSites: {},
};
const realFetch = globalThis.fetch;
afterEach(() => {
delete global.chrome;
globalThis.fetch = realFetch;
});
// The background, over a stored profile, with a node that answers each
// JSON-RPC method from `node`: a method's entry is the result, or
// `{ error }` for the error the node answers with. A method not in `node`
// is answered "method not found", so a test that reaches one fails on it.
// `node` null is a node that cannot be reached at all.
function loadBackground(profile, node) {
jest.resetModules();
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(),
}));
function reply({ id, method }) {
const entry = node[method];
if (entry === undefined) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: "method not found: " + method },
};
}
if (entry && entry.error)
return { jsonrpc: "2.0", id, error: entry.error };
return { jsonrpc: "2.0", id, result: entry };
}
globalThis.fetch = jest.fn(async (url, opts) => {
if (!node) throw new TypeError("fetch failed");
return { status: 200, json: async () => reply(JSON.parse(opts.body)) };
});
// The ethers the background is about to load: jest.resetModules() above
// means it is a fresh copy, not the one this file required at the top.
require("ethers").FetchRequest.registerGetUrl(async (req) => {
if (!node) throw new Error("connect ECONNREFUSED");
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
let messageListener = null;
global.chrome = {
storage: makeStorageStub({ autistmask: profile }),
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: { onRemoved: { addListener: () => {} } },
action: { setPopup: () => {} },
};
require("../src/background/index");
// The answer the page gets to one request.
return function request(method, params) {
return new Promise((resolve) => {
messageListener(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: ORIGIN },
resolve,
);
});
};
}
const TRANSFER = {
from: ADDRESS,
to: TOKEN,
// transfer(ADDRESS, 1)
data:
"0xa9059cbb" +
AbiCoder.defaultAbiCoder()
.encode(["address", "uint256"], [ADDRESS, 1])
.slice(2),
};
describe("a node's error reaches the page with the node's code and data", () => {
test("a reverting eth_call", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_call: { error: REVERTED },
});
const answer = await request("eth_call", [TRANSFER, "latest"]);
expect(answer).toEqual({ error: REVERTED });
});
test("an eth_getBalance for an address that is not one", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_getBalance: { error: INVALID_ADDRESS },
});
const answer = await request("eth_getBalance", ["0x1234", "latest"]);
expect(answer).toEqual({ error: INVALID_ADDRESS });
});
// The wallet's own sentence, with the node's code and revert data, so the
// site can decode why its transaction would revert.
test("an eth_sendTransaction whose gas estimate reverts", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_getTransactionCount: "0x7",
eth_estimateGas: { error: REVERTED },
});
const answer = await request("eth_sendTransaction", [TRANSFER]);
expect(answer).toEqual({
error: {
code: REVERTED.code,
message: expect.stringMatching(
/^The transaction could not be prepared: execution reverted/,
),
data: REVERT_DATA,
},
});
});
});
// One request for each way a request is refused without the node's code: by
// the wallet itself, or because the node could not be reached.
describe("no rejected request reaches the page without a numeric code", () => {
test.each([
[
"eth_requestAccounts with no address",
NO_ADDRESS,
{},
"eth_requestAccounts",
[],
4100,
],
[
"personal_sign with no address",
NO_ADDRESS,
{},
"personal_sign",
["0x00", ADDRESS],
4100,
],
[
"eth_signTypedData_v4 with no address",
NO_ADDRESS,
{},
"eth_signTypedData_v4",
[ADDRESS, "{}"],
4100,
],
[
"eth_sendTransaction with no address",
NO_ADDRESS,
{},
"eth_sendTransaction",
[TRANSFER],
4100,
],
[
"eth_sendTransaction with two different gas limits",
WITH_ADDRESS,
{},
"eth_sendTransaction",
[{ ...TRANSFER, gas: "0x5208", gasLimit: "0x5209" }],
-32602,
],
[
"eth_call to a node that cannot be reached",
WITH_ADDRESS,
null,
"eth_call",
[TRANSFER, "latest"],
-32603,
],
[
"eth_sendTransaction to a node that cannot be reached",
WITH_ADDRESS,
null,
"eth_sendTransaction",
[TRANSFER],
-32603,
],
])("%s", async (_name, profile, node, method, params, code) => {
const request = loadBackground(profile, node);
const answer = await request(method, params);
expect(answer.error.code).toBe(code);
expect(answer.error.message).toEqual(expect.any(String));
});
});
+1 -1
View File
@@ -74,7 +74,7 @@ test("ethers puts the URL in the error message, not in the short message", async
test("the recipient checks before a send", async () => { test("the recipient checks before a send", async () => {
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet")); await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
expectFailureLoggedWithoutKey("contract check failed"); expectFailureLoggedWithoutKey("contract check failed");
expectFailureLoggedWithoutKey("tx count check failed"); expectFailureLoggedWithoutKey("tx count or balance check failed");
}); });
test("the ENS reverse lookup", async () => { test("the ENS reverse lookup", async () => {
+220
View File
@@ -0,0 +1,220 @@
// The Send screen refuses every amount the confirmation screen would refuse,
// by the same check (https://git.eeqj.de/sneak/AutistMask/issues/532).
//
// Send used to accept anything parseFloat() read as above zero, so "1e-3",
// "1abc" and an amount with more decimal places than the token has went on to
// a confirmation screen that then refused them. Both screens now call
// isValidAmount() in src/shared/txValidation.js.
//
// Driven through the real Send review handler and the real confirmation
// screen, with only the node and the DOM stubbed.
"use strict";
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
const { AbiCoder } = require("ethers");
// What the confirmation screen asks the node for. The address holds the 1 ETH
// and 250 TOK the wallet below records.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 1000000000000000000n,
// The one contract call the screen makes, balanceOf(), answered at the
// token's 6 decimal places.
call: async () =>
AbiCoder.defaultAbiCoder().encode(["uint256"], [250000000n]),
};
jest.mock("../src/shared/balances", () => {
const actual = jest.requireActual("../src/shared/balances");
return { ...actual, getProvider: () => mockProvider };
});
// The confirmation screen's Etherscan label lookup 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(), 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);
},
appendChild(child) {
return child;
},
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 { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token with 6 decimal places, as USDC has.
const TOKEN = "0x" + "d".repeat(40);
const TOKEN_BY_SYMBOL = { ETH: "ETH", TOK: TOKEN };
function el(id) {
return global.document.getElementById(id);
}
// Press Review on the Send screen with `amount` entered, for a send of `token`
// ("ETH" or TOKEN), and return what it handed the confirmation screen, or null
// when it handed nothing over.
async function review(amount, token) {
let handed = null;
send.init({ showConfirmTx: (info) => (handed = info) });
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = amount;
await el("btn-send-review").handlers.get("click")();
return handed;
}
// Show the confirmation screen for the send Review hands over for 0.5, with
// `amount` in its place, and let its fee estimate settle.
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
beforeEach(() => {
elements.clear();
state.wallets = [
{
name: "Wallet 1",
addresses: [
{
address: HOLDER,
balance: "1.0",
tokenBalances: [
{
address: TOKEN,
name: "Token",
symbol: "TOK",
decimals: 6,
balance: "250.0",
holders: 50000,
},
],
},
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 6 },
];
state.fraudContracts = [];
state.selectedToken = null;
state.currentView = null;
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
["1abc", "ETH"],
// 19 decimal places; ETH has 18.
["0.0000000000000000001", "ETH"],
// 7 decimal places; the token has 6.
["0.0000001", "TOK"],
["0", "ETH"],
["-1", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send refuses it with a flash message and opens nothing", async () => {
expect(await review(amount, token)).toBe(null);
expect(el("flash-msg").textContent).toBe(
"Please enter a valid amount.",
);
});
test("the confirmation screen refuses it too", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toContain(
"Please enter a valid amount to send.",
);
expect(el("btn-confirm-send").disabled).toBe(true);
});
});
describe.each([
["0.5", "ETH"],
["0.5", "TOK"],
// Exactly the 6 decimal places the token has.
["0.000001", "TOK"],
// Only zeros past the token's 6 places, and past ETH's 18.
["1.0000000", "TOK"],
["0.5000000000000000000", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send hands it to the confirmation screen", async () => {
expect((await review(amount, token)).amount).toBe(amount);
expect(el("flash-msg").textContent).toBe("");
});
test("the confirmation screen lets it be sent", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("btn-confirm-send").disabled).toBe(false);
});
});
+27 -8
View File
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs. // What the stub node answers. Each test sets what it needs.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n }, feeData: { maxFeePerGas: 1n, gasPrice: 1n },
}; };
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -195,10 +203,19 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML; return global.document.getElementById("confirm-errors").innerHTML;
} }
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports, // The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -300,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "Your TOK balance does not cover this amount.",
); );
}); });
@@ -316,6 +334,7 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("1.5", TOKEN); await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK"); expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
}); });
test("with more than 18 decimals and a holding below 10^-18", async () => { test("with more than 18 decimals and a holding below 10^-18", async () => {
@@ -329,9 +348,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
); );
await confirmSend("0.000000000000000001", TOKEN); await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" + "Your TOK balance does not cover this amount.",
" 0.000000000000000001 TOK.",
); );
}); });
}); });
@@ -356,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "Your TOK balance does not cover this amount.",
); );
}); });
}); });
+13 -1
View File
@@ -17,6 +17,7 @@
// What the stub node answers, and the signed transactions it was handed. // What the stub node answers, and the signed transactions it was handed.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: null, feeData: null,
broadcast: [], broadcast: [],
}; };
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make before Sign
// & Send.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -187,9 +196,12 @@ function text(id) {
} }
// The ETH balance the node reports and the token rows the explorer reports, // The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [ state.wallets = [
{ {
+28
View File
@@ -43,6 +43,12 @@ jest.doMock("ethereum-blockies-base64", () => makeBlockie);
// Taken before any boot replaces them; see bootPopup(). // Taken before any boot replaces them; see bootPopup().
const realSetInterval = globalThis.setInterval; const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval; const realClearInterval = globalThis.clearInterval;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
// Every timeout set since the last cleanupPopup() that has neither fired nor
// been cleared. cleanupPopup() clears them all.
const pendingTimeouts = new Set();
const POPUP_HTML = fs.readFileSync( const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"), path.join(__dirname, "..", "..", "src", "popup", "index.html"),
@@ -342,6 +348,24 @@ async function bootPopup(stored, options) {
globalThis.clearInterval = (id) => { globalThis.clearInterval = (id) => {
intervals.delete(id); intervals.delete(id);
}; };
// Timeouts, such as the one that clears a flash message, run for real, but
// each is recorded until it fires or is cleared, so cleanupPopup() can
// clear the ones still pending. One that fired after the page was removed
// would throw "document is not defined" into whatever test the worker runs
// by then. jest.useFakeTimers() in a test does not reach them. Put back by
// cleanupPopup().
globalThis.setTimeout = (fn, ms, ...args) => {
const timer = realSetTimeout(() => {
pendingTimeouts.delete(timer);
fn(...args);
}, ms);
pendingTimeouts.add(timer);
return timer;
};
globalThis.clearTimeout = (timer) => {
pendingTimeouts.delete(timer);
realClearTimeout(timer);
};
require("../../src/popup/index"); require("../../src/popup/index");
@@ -407,6 +431,10 @@ function cleanupPopup() {
delete globalThis.window; delete globalThis.window;
globalThis.setInterval = realSetInterval; globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval; globalThis.clearInterval = realClearInterval;
for (const timer of pendingTimeouts) realClearTimeout(timer);
pendingTimeouts.clear();
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
} }
module.exports = { module.exports = {
+23 -15
View File
@@ -1,4 +1,4 @@
// A chrome.storage.local stub that behaves like the real one. // A chrome.storage stub that behaves like the real one.
// //
// The real extension storage API is a serialization boundary: `set` writes a // The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured // structured clone of what it is given, and `get` hands back a structured
@@ -17,13 +17,8 @@
// So every test that drives real persistence uses this, and nothing rebuilds // So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand. // a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }. // One storage area: get, set and remove over its own store, cloning both ways.
// `onOp` runs before each operation, for a test that needs to advance a clock function makeArea(store, tick) {
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const get = jest.fn(async (key) => { const get = jest.fn(async (key) => {
tick(); tick();
if (key === undefined || key === null) return structuredClone(store); if (key === undefined || key === null) return structuredClone(store);
@@ -49,15 +44,28 @@ function makeStorageStub(initial, onOp) {
for (const k of Array.isArray(key) ? key : [key]) delete store[k]; for (const k of Array.isArray(key) ? key : [key]) delete store[k];
}); });
return { get, set, remove };
}
// `initial` is the starting contents of `storage.local`, keyed as storage is:
// { autistmask: {...} }. `storage.session` starts empty.
// `onOp` runs before each operation on either area, for a test that needs to
// advance a clock or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
return { return {
// Drop this straight in as chrome.storage. // Drop this straight in as chrome.storage.
local: { get, set, remove }, local,
get, session: makeArea({}, tick),
set, get: local.get,
remove, set: local.set,
// What is stored, cloned on the way out: a test can neither observe a remove: local.remove,
// later write through an object it read nor reach into the store by // What is stored in `storage.local`, cloned on the way out: a test can
// mutating one. // neither observe a later write through an object it read nor reach
// into the store by mutating one.
read: (key) => read: (key) =>
key === undefined key === undefined
? structuredClone(store) ? structuredClone(store)
+6
View File
@@ -116,6 +116,12 @@ describe("transferAmountUnits", () => {
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow(); expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
}); });
test("encodes zeros past the token's scale as the amount typed", () => {
// The Send screen accepts "1.0000000" for a 6-place token, so the
// transfer must move exactly 1 of it.
expect(transferAmountUnits("1.0000000", 6, 6n)).toBe(1000000n);
});
test("every refusal is a full sentence", () => { test("every refusal is a full sentence", () => {
const messages = []; const messages = [];
for (const args of [ for (const args of [
+65
View File
@@ -6,6 +6,7 @@ const {
FEE_UNAVAILABLE, FEE_UNAVAILABLE,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
isValidAmount,
maxEthAmount, maxEthAmount,
maxTokenAmount, maxTokenAmount,
toFixedPoint, toFixedPoint,
@@ -33,6 +34,49 @@ describe("toFixedPoint", () => {
}); });
}); });
// The one amount check the Send screen and the confirmation screen share.
// The screens themselves are driven in tests/sendAmountRule.test.js.
describe("isValidAmount", () => {
test("accepts a number above zero written as digits and a decimal point", () => {
expect(isValidAmount("0.5")).toBe(true);
expect(isValidAmount("12")).toBe(true);
expect(isValidAmount(".5")).toBe(true);
expect(isValidAmount("0.000000000000000001")).toBe(true);
});
test("refuses an exponent, trailing text, zero and a negative amount", () => {
expect(isValidAmount("1e-3")).toBe(false);
expect(isValidAmount("1abc")).toBe(false);
expect(isValidAmount("0")).toBe(false);
expect(isValidAmount("0.0")).toBe(false);
expect(isValidAmount("-1")).toBe(false);
expect(isValidAmount("")).toBe(false);
expect(isValidAmount(undefined)).toBe(false);
});
test("refuses a digit other than 0 past the token's places", () => {
expect(isValidAmount("0.000001", 6)).toBe(true);
expect(isValidAmount("0.0000001", 6)).toBe(false);
expect(isValidAmount("5", 0)).toBe(true);
expect(isValidAmount("0.5", 0)).toBe(false);
});
test("accepts zeros past the token's places, which leave the amount unchanged", () => {
expect(isValidAmount("1.0000000", 6)).toBe(true);
expect(isValidAmount("5.0", 0)).toBe(true);
expect(isValidAmount("1.0000000000000000000")).toBe(true);
expect(isValidAmount("0.0000000", 6)).toBe(false);
});
test("holds ETH, a token of unknown places, and any token to 18 places", () => {
const nineteen = "0.0000000000000000001";
expect(isValidAmount(nineteen)).toBe(false);
expect(isValidAmount(nineteen, null)).toBe(false);
expect(isValidAmount(nineteen, 24)).toBe(false);
expect(isValidAmount("0.000000000000000001", 24)).toBe(true);
});
});
describe("validateTransfer, native ETH", () => { describe("validateTransfer, native ETH", () => {
const eth = (over) => ({ const eth = (over) => ({
isErc20: false, isErc20: false,
@@ -134,6 +178,17 @@ describe("validateTransfer, native ETH", () => {
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] }); ).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
}); });
test("rejects a zero amount and an exponent", () => {
expect(validateTransfer(eth({ amount: "0" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
expect(validateTransfer(eth({ amount: "1e-3" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
});
test("treats a missing balance as zero, not as unlimited", () => { test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined })); const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]); expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
@@ -205,6 +260,16 @@ describe("validateTransfer, ERC-20", () => {
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] }); expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
}); });
test("rejects more decimal places than the token has", () => {
const r = validateTransfer(
erc20({ amount: "0.0000001", tokenDecimals: 6 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(erc20({ amount: "0.000001", tokenDecimals: 6 })),
).toEqual({ canSend: true, codes: [] });
});
test("treats a missing token balance as zero", () => { test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined })); const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]); expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
+474 -20
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) {
// V4 inner action IDs, as src/shared/uniswap.js names them. // V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06; const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b; const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e; const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
@@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
); );
} }
// Helper: V4 ExactOutputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
function encodeV4ExactOutSingle(
currency0,
currency1,
amountOut,
amountInMaximum,
) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountOut,
amountInMaximum,
"0x",
],
],
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has // Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since // deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC. // moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
@@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) {
); );
} }
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The // V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
const V4_SETTLE_ALL_ID = 0x0c; const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f; const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) { function encodeV4SettleAll(currency, maxAmount) {
@@ -279,6 +303,12 @@ function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]); return coder.encode(["address", "uint256"], [currency, minAmount]);
} }
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
const V4_CLOSE_CURRENCY_ID = 0x12;
function encodeV4CloseCurrency(currency) {
return coder.encode(["address"], [currency]);
}
function detail(result, label) { function detail(result, label) {
return result.details.find((d) => d.label === label); return result.details.find((d) => d.label === label);
} }
@@ -358,10 +388,9 @@ describe("uniswap decoder", () => {
}, },
); );
// Only the unread swap action names the currencies here. The token lines // SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)( test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded", "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
({ id, newer }) => { ({ id, newer }) => {
const data = buildExecute( const data = buildExecute(
"0x10", "0x10",
@@ -381,6 +410,27 @@ describe("uniswap decoder", () => {
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
); );
@@ -394,7 +444,7 @@ describe("uniswap decoder", () => {
// action names. The V3 step's WETH and its Min. received figure were // action names. The V3 step's WETH and its Min. received figure were
// shown instead. // shown instead.
test.each(V4_SWAP_ACTION_SHAPES)( test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output", "a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
({ id, newer }) => { ({ id, newer }) => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]), solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
@@ -403,10 +453,7 @@ describe("uniswap decoder", () => {
USDT_ADDR, USDT_ADDR,
WETH_ADDR, WETH_ADDR,
]), ]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ encodeV4Swap(new Uint8Array([id]), [newer]),
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
], ],
9999999999n, 9999999999n,
); );
@@ -423,15 +470,12 @@ describe("uniswap decoder", () => {
// The user pays the V4 step's WETH, which only its unread swap action // The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead. // names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)( test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input", "a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
({ id, newer }) => { ({ id, newer }) => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]), solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[ [
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [ encodeV4Swap(new Uint8Array([id]), [newer]),
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV3SwapExactIn( encodeV3SwapExactIn(
USER_ADDR, USER_ADDR,
990000n, 990000n,
@@ -458,10 +502,7 @@ describe("uniswap decoder", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]), solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[ [
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ encodeV4Swap(new Uint8Array([id]), [newer]),
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
], ],
9999999999n, 9999999999n,
@@ -1124,6 +1165,419 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several
// swap actions. The screen showed the first action's output as Token Out.
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
// USDC whichever kind of swap actions it holds and in whatever order they
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
],
params: [
encodeV4CloseCurrency(USDT_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
],
},
];
// 1,000 USDT for at least 0.5 WETH
const USDT_FOR_WETH_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
500000000000000000n,
),
};
// 0.5 WETH for at least 990 USDC
const WETH_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
500000000000000000n,
990000000n,
),
};
// exactly 0.5 WETH for at most 1,000 USDT
const USDT_FOR_WETH_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
500000000000000000n,
1000000000n,
),
};
// exactly 990 USDC for at most 0.5 WETH
const WETH_FOR_USDC_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
};
// 400 USDT for at least 396 USDC
const USDT_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
400000000n,
396000000n,
),
};
// 600 USDT through WETH for at least 594 USDC
const USDT_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
600000000n,
594000000n,
),
};
// the open delta of USDT through WETH for at least 594 USDC
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
0n,
594000000n,
),
};
// Amount is the amountIn of the action that spends USDT, or the sum when
// several exact-in actions each state one. Min. received is the same for
// the actions that buy USDC. The decoder reads no amount from an exact-out
// action, and an open delta is not a stated amount.
const V4_STEPS = [
{
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
amount: undefined,
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
amount: undefined,
min: "990.0000 USDC",
},
];
test.each(
V4_STEPS.flatMap((step) =>
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
[...swaps.map((s) => s.param), ...params],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
},
);
// A step with a swap action that could not be read is named only by its
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
// USDT for WETH action names neither side. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
// shape.
test.each([
{
...V4_STEP_ENDINGS[0],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[1],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[2],
tokenIn: "Unknown (could not be decoded)",
tokenOut: "Unknown (could not be decoded)",
},
])(
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
({ ids, params, tokenIn, tokenOut }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
V4_SWAP_ACTION_SHAPES[0].id,
...ids,
]),
[
USDT_FOR_WETH_IN.param,
V4_SWAP_ACTION_SHAPES[0].newer,
...params,
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(tokenIn);
expect(detail(result, "Token Out").value).toBe(tokenOut);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown.
const WETH_FOR_USDC_AND_DAI = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 0.25 WETH for at least 495 USDC
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
250000000000000000n,
495000000n,
),
// 0.25 WETH for at least 495 DAI
encodeV4ExactInSingle(
WETH_ADDR,
DAI_ADDR,
250000000000000000n,
495000000000000000000n,
),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
],
);
// The user ends with USDC and DAI, not the V3 step's WETH. That WETH and
// the V3 step's Min. received figure were shown instead.
test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(
USER_ADDR,
1000000000n,
500000000000000000n,
[USDT_ADDR, WETH_ADDR],
),
WETH_FOR_USDC_AND_DAI,
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received")).toBeUndefined();
});
// The input side of the same gap: the V4 step's swap actions spend USDC
// and DAI, two currencies that none of them buys, and nothing names its
// input side. The V3 step's WETH and its amount were shown instead.
test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 500 USDC for at least 0.25 WETH
encodeV4ExactInSingle(
USDC_ADDR,
WETH_ADDR,
500000000n,
250000000000000000n,
),
// 500 DAI for at least 0.25 WETH
encodeV4ExactInSingle(
DAI_ADDR,
WETH_ADDR,
500000000000000000000n,
250000000000000000n,
),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
],
),
encodeV3SwapExactIn(
USER_ADDR,
500000000000000000n,
990000000n,
[WETH_ADDR, USDT_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
// UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4
// step with a swap action did set it, to unknown here.
test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
WETH_FOR_USDC_AND_DAI,
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
});
// The router works through an exact-out path from its last entry back to
// its first: the WETH entry buys the USDC, then the USDT entry buys that
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([0x09]), [
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
],
990000000n,
1000000000n,
],
],
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2 // https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router // exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and // Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
+51 -38
View File
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// Everything the confirmation screen would reach the network for. The gas // Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure // estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub. // in these tests is a failure of the scale and not of the stub. The address
// holds the 1 ETH the wallets below record, and `tokenUnits` of the token.
const mockProvider = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, getBalance: async () => 1000000000000000000n,
tokenUnits: 0n,
// The one contract call the screen makes, balanceOf().
call: async () =>
AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockProvider.tokenUnits],
),
}; };
jest.mock("../src/shared/balances", () => { jest.mock("../src/shared/balances", () => {
@@ -114,7 +122,7 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers"); const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances"); const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -155,8 +163,9 @@ function row(token = {}, value = FIVE_WETH) {
} }
// Fetch the explorer's rows through the real fetcher and put them exactly where // Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them. // refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) { async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach // is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows. // explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) { async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -239,6 +249,12 @@ function errors() {
return el("confirm-errors").innerHTML; return el("confirm-errors").innerHTML;
} }
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = el("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
function sendDisabled() { function sendDisabled() {
return el("btn-confirm-send").disabled; return el("btn-confirm-send").disabled;
} }
@@ -270,7 +286,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
const txInfo = await reviewSend(WETH, "1.5"); const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18); expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals); expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
}); });
test("that scale estimates a fee and leaves Send enabled", async () => { test("that scale estimates a fee and leaves Send enabled", async () => {
@@ -307,11 +322,10 @@ describe("the Send screen resolves the scale rather than reading the stored one"
// No fallback was introduced: resolution answers null here, and the // No fallback was introduced: resolution answers null here, and the
// null is what goes forward. // null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown"); expect(balanceError()).toContain("This token's balance is unknown");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
}); });
}); });
@@ -355,7 +369,8 @@ describe("a scale the explorer's own rows disagree about", () => {
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance. // The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
}); });
test("so the user is told the balance is unknown, not only that the fee failed", async () => { test("so the user is told the balance is unknown, not only that the fee failed", async () => {
@@ -367,8 +382,7 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be // confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation // estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed. // for a screen that can never proceed.
expect(errors()).not.toBe(""); expect(balanceError()).toContain("This token's balance is unknown");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it // The fee line still reports the estimate as unavailable, because it
@@ -434,31 +448,20 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL"); expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6); expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL"); expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
}); });
describe("the confirmation screen tells an unknown balance from a zero one", () => { describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) { // A send of 1.5 NOVEL from an address the node reports none of, with the
return { // token's scale `tokenDecimals`, or null for a scale nobody knows.
from: HOLDER, async function render(tokenDecimals) {
to: RECIPIENT, mockProvider.tokenUnits = 0n;
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -469,30 +472,40 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance)); confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
tokenSymbol: "NOVEL",
tokenDecimals,
});
await settle(); await settle();
return { balance: text("confirm-balance"), errors: errors() }; return { balance: text("confirm-balance"), message: balanceError() };
} }
test("the balance line states unknown rather than a quantity of zero", async () => { test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.balance).not.toBe(zero.balance); expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)"); expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL"); expect(zero.balance).toBe("0.0000 NOVEL");
}); });
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to // Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim // spend from, which is the fail-closed side — but "your balance does not
// about the holding, and this one has no established quantity to claim. // cover this amount" is a claim about the holding, and this one has no
test("the insufficient-balance message names the reason, not a figure", async () => { // established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.errors).not.toBe(zero.errors); expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.message).not.toContain("does not cover");
expect(unknown.errors).not.toContain("You have"); expect(zero.message).toBe(
expect(zero.errors).toContain("You have 0.0000 NOVEL"); "Your NOVEL balance does not cover this amount. Please go back and" +
expect(zero.errors).not.toContain("balance is unknown"); " send a smaller amount.",
);
}); });
}); });