Author SHA1 Message Date
sneak f9318bafec fix: tracked tokens are kept per network, and bundled picks are offered on mainnet only (closes #523)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch now puts the outgoing network's tracked tokens away in a new
persisted field, networkTrackedTokens, and brings back the incoming network's
list, or an empty one, the way endpoints are already kept per network.
trackedTokens stays the active network's list, so its readers are unchanged,
and the background's switch keeps the lists too because it goes through the
same function. The two add-token screens show their common-token picks, which
come from the bundled list of mainnet contracts, on Ethereum Mainnet only;
adding by contract address works on every network. Settings redraws its
tracked-token list after a switch.

Model: opus-5-5
2026-10-18 23:22:32 +00: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
36 changed files with 2005 additions and 258 deletions
+77 -34
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
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`)
`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
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
connected and the RPC endpoint set through the extension's own screens, against
the loopback dApp page and stub node the Firefox suite also uses
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors.
connected, without "Remember my choice for this site", and the RPC endpoint set
through the extension's own screens, against the loopback dApp page and stub
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`)
@@ -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
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`,
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
site's request waiting on it, live only in the worker's memory, and only the
@@ -1544,8 +1559,14 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message.
**Home**. When no address is active yet, as on the first wallet, the new
wallet's first address becomes the active one. The phrase and xprv modes
then scan for further used addresses and report the count as a flash
message. When the scan finds two or more used addresses, they replace the
wallet's list, which then holds the first address only if it was used. If
the first address was active and is not in the new list, the new list's
first address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast.
- "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change
- "Import" with an invalid entry or a duplicate wallet or address → flash
@@ -1672,7 +1693,13 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "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)
#### ConfirmTx (`confirm-tx`)
@@ -1871,7 +1898,8 @@ view would leave a wallet one click from deletion.
- Contract address input
- Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons (top 25 by market cap), which fill the
contract address input
contract address input. Shown on Ethereum Mainnet only, because the
bundled token list holds mainnet contracts
- "Add" button
- **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
@@ -1888,16 +1916,19 @@ view would leave a wallet one click from deletion.
- Wallets: one row per wallet with its name (tap to rename inline), a
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button
- Tracked Tokens: one row per token tracked on the active network with an
`[x]` remove button, plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
network changes only here, or when the user approves a site's request on
**NetworkApproval**; either way, switching restores the RPC and Blockscout
endpoints last used on that network, or that network's defaults if it has
none, and sends `chainChanged` with the new chain id to every open tab.
Choosing the network already active changes nothing and sends nothing
none, and the tokens tracked on that network, and sends `chainChanged`
with the new chain id to every open tab. A token is tracked only on the
network it was added on, because a contract address names a token on one
network only. Choosing the network already active changes nothing and
sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
@@ -1916,7 +1947,7 @@ view would leave a wallet one click from deletion.
under any address, with remove buttons
- Connected Sites: the origins of the sites allowed without "Remember my
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`
- Denied Sites: the origins remembered as denied, under any address, with
remove buttons
@@ -1935,9 +1966,10 @@ view would leave a wallet one click from deletion.
its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. 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
`accountsChanged` with an empty list to the open tabs of that origin.
Settings waits for the background's answer before it draws the lists
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
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change)
@@ -1998,8 +2030,8 @@ view would leave a wallet one click from deletion.
(`src/shared/walletDelete.js`)
- Either way, every address the wallet held loses its site permissions of
both kinds: the remembered ones in storage, and the connections approved
without "Remember", which only the background holds, in memory, and drops
on `AUTISTMASK_ADDRESSES_REMOVED`
without "Remember", which only the background holds, in session storage,
and drops on `AUTISTMASK_ADDRESSES_REMOVED`
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
@@ -2112,15 +2144,16 @@ view would leave a wallet one click from deletion.
one in this wallet follows the splice.
- 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
without "Remember", which only the background holds, in memory, and drops on
`AUTISTMASK_ADDRESSES_REMOVED`.
without "Remember", which only the background holds, in session storage, and
drops on `AUTISTMASK_ADDRESSES_REMOVED`.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed.
#### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
across every address, unlike AddToken which is reached from one address.
across every address on the active network, unlike AddToken which is reached
from one address.
- **Elements**:
- "Back" button, "Add Token" heading
- Instruction text
@@ -2129,8 +2162,12 @@ view would leave a wallet one click from deletion.
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
"Add selected" button
- "Or enter contract address:" input, a status line, and an "Add" button
- The two lists from the bundled token list, "Top tokens:" and "Or pick from
top 100:", are shown on Ethereum Mainnet only, because that list holds
mainnet contracts. On any other network the contract address is the only
add path
- **Transitions**:
- Any of the three add paths, on success → adds the token and shows an
- Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
@@ -2147,14 +2184,17 @@ view would leave a wallet one click from deletion.
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
sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports
for a local file. The background script prefers the toolbar popup
(`action.openPopup()`) and falls back to a separate popup window
(`src/background/index.js`, `requestApproval()`). Only one exists per site at
a time: a further connection request from a site whose prompt is 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.
for a local file. Nor is such a page told the active address by
`accountsChanged`, even at a connected site's URL: the background names the
origin of the tab's URL on the event, and the content script passes the
address on only to a page whose own origin is that one. The background script
prefers the toolbar popup (`action.openPopup()`) and falls back to a separate
popup window (`src/background/index.js`, `requestApproval()`). Only one exists
per site at a time: a further connection request from a site whose prompt is
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**:
- "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
@@ -2186,8 +2226,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
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
is refused. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
is refused with EIP-1474 code `-32602`. A request that cannot be populated —
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
`eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
@@ -2343,8 +2385,9 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Switch" → closes popup; the background switches the network as
**Settings** does, restoring the RPC and Blockscout endpoints last used on
that network (or that network's defaults if it has none), sends
`chainChanged` to every open tab, and answers the site with success
that network (or that network's defaults if it has none) and the tokens
tracked on that network, sends `chainChanged` to every open tab, and
answers the site with success
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
nothing changes
- Popup window closed without answering → the same as "Reject"
+2 -1
View File
@@ -27,7 +27,8 @@
"web_accessible_resources": ["src/content/inpage.js"],
"browser_specific_settings": {
"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)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IIDFILE=""
# 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
# 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() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# 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
}
@@ -40,22 +56,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
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)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
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
# 64MB /dev/shm or renderers crash.
# 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
# on a deliberate bump.
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/run.js
# 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.
echo "Running the extension-id and storage-partition observations..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/storagePartition.js
# 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.
echo "Running the approvals left open for seven minutes..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/slowApproval.js
}
+22 -16
View File
@@ -21,13 +21,23 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
# A tag and a container name of this run's own, as in script/test-e2e,
# which says why.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IMAGE="$NAME:$SUFFIX"
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# 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
}
@@ -39,22 +49,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
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)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
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.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
@@ -69,10 +73,11 @@ main() {
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it.
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI
@@ -82,10 +87,11 @@ main() {
# and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/reinstall.js
}
+130 -60
View File
@@ -26,6 +26,7 @@ const {
typedDataMessageRefusal,
typedDataChainRefusal,
failureIsRetryable,
nodeError,
describeTxFailure,
sameAddress,
ApprovalMismatchError,
@@ -48,6 +49,9 @@ const {
actionApi,
getPlatformInfo,
runtimeApi,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -60,12 +64,31 @@ const runtime = runtimeApi();
const windowsNs = windowsApi();
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
// remembered allowedSites/deniedSites lists alike: a grant to
// 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 } }
const pendingApprovals = {};
@@ -272,6 +295,21 @@ function failureError(err) {
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
// 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
@@ -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) {
const rpcUrl = await getRpcUrl();
const resp = await debugFetch(rpcUrl, {
@@ -332,7 +371,10 @@ async function proxyRpc(method, params) {
});
const json = await resp.json();
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;
}
@@ -728,7 +770,7 @@ async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) {
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
}
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 (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -784,7 +826,7 @@ async function handleConnectionRequest(origin) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin);
} else {
connectedSites[origin + ":" + activeAddress] = true;
await sessionStorageSet({ [origin + ":" + activeAddress]: true });
}
return { result: [activeAddress] };
} else {
@@ -838,7 +880,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -874,7 +916,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -967,10 +1009,10 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
const hasPermission =
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
(await isConnected(origin, activeAddress));
if (!hasPermission || !activeAddress) {
return { result: [] };
}
return {
@@ -992,12 +1034,12 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1046,12 +1088,12 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1093,12 +1135,13 @@ async function handleRpc(method, params, origin) {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
// A node that answered with an error is reported as itself. The
// wallet being unable to read its own profile is not that, and it
// must not be flattened into a message with no code: it goes back
// to the dispatcher, which has the one answer for it.
// A node that answered with an error is reported as itself, with
// its own code and data. The wallet being unable to read its own
// profile is not that, and it must not be answered as an ordinary
// failure: it goes back to the dispatcher, which has the one
// answer for it.
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) {
const s = await getState();
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] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1173,7 +1218,7 @@ async function handleSendTransaction(params, origin) {
txParams,
);
} 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
@@ -1183,10 +1228,9 @@ async function handleSendTransaction(params, origin) {
// while the page is still waiting and nothing has been displayed.
if (!sameAddress(await getActiveAddress(), activeAddress)) {
return {
error: {
message:
"The active address changed while this transaction was being prepared, so it was not sent.",
},
error: requestError(
"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
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
// approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the
@@ -1254,6 +1294,10 @@ async function broadcastAccountsChanged() {
}
}
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 activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
@@ -1264,17 +1308,24 @@ async function broadcastAccountsChanged() {
return;
}
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 hasPermission =
activeAddress &&
(allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]);
(await isConnected(origin, activeAddress)));
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
origin,
}).catch(() => {});
}
}
@@ -1679,7 +1730,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (outcome.spendApproval) {
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
} else {
@@ -1706,7 +1757,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
sendResponse({
@@ -1739,10 +1790,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// 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);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error, nodeError(e)) },
{ holdsClaim: true },
);
sendResponse({
@@ -1864,7 +1918,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!retryable) {
settleApproval(
msg.id,
{ error: { message: errMsg } },
{ error: requestError(errMsg) },
{ holdsClaim: true },
);
} else {
@@ -1892,41 +1946,57 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// 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") {
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
for (const key of Object.keys(connectedSites)) {
endConnections((key) => {
const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) {
delete connectedSites[key];
}
}
return removed.some((a) => sameAddress(a, address));
}).catch((err) => {
log.errorf(
"ending the removed addresses' connections failed:",
err,
);
});
return false;
}
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
// Settings lists the sites connected without "Remember", which only
// session storage holds.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
key.slice(0, key.lastIndexOf(":")),
),
);
return false;
sessionStorageGet(null)
.then((items) =>
Object.keys(items).map((key) =>
key.slice(0, key.lastIndexOf(":")),
),
)
.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
// remembered entries. Its connections approved without "Remember" end
// 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") {
for (const key of Object.keys(connectedSites)) {
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.origin);
return false;
endConnections(
(key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
)
.catch((err) => {
log.errorf(
"ending the removed site's connections failed:",
err,
);
})
.then(() => {
broadcastSiteRemoved(msg.origin);
sendResponse(null);
});
return true;
}
// 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)
runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") {
window.postMessage(
{
type: "AUTISTMASK_EVENT",
eventName: msg.eventName,
data: msg.data,
},
"*",
);
if (msg.type !== "AUTISTMASK_EVENT") return;
// 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.
// This page hears them only when its own origin is that one. A page whose
// origin has no host is not a site: one its server sandboxes with a
// 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,
},
"*",
);
});
+31 -28
View File
@@ -843,7 +843,7 @@
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2">
<div id="add-token-common" class="mb-2">
<label class="block mb-1 text-xs text-muted"
>Or pick a common token:</label
>
@@ -1326,36 +1326,39 @@
<h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually.
Common tokens are offered on Ethereum Mainnet only.
</p>
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<div id="settings-addtoken-common">
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
</div>
</div>
<!-- manual contract address -->
+6
View File
@@ -9,6 +9,12 @@ function show() {
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
// The bundled list holds Ethereum Mainnet contracts, which name no token
// on any other network.
$("add-token-common").classList.toggle(
"hidden",
state.networkId !== "mainnet",
);
const list = $("common-token-list");
list.innerHTML = getTopTokens(25)
.map(
+25
View File
@@ -20,6 +20,7 @@ const {
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
const { notify } = require("../../shared/browserApi");
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
@@ -194,6 +195,9 @@ async function importMnemonic(ctx) {
};
state.wallets.push(wallet);
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -215,8 +219,16 @@ async function importMnemonic(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
// The new list leaves out the first address when the scan found it
// unused. If it was the active one, the new list's first takes over,
// and the background is told as when the user picks one on Home.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState();
ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
@@ -254,6 +266,9 @@ async function importPrivateKey(ctx) {
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = addr;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -315,6 +330,9 @@ async function importXprvKey(ctx) {
};
state.wallets.push(wallet);
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -336,8 +354,15 @@ async function importXprvKey(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
// As for a recovery phrase: an active first address the scan left
// out gives way to the new list's first.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState();
ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
+1
View File
@@ -278,6 +278,7 @@ function renderValidation(txInfo) {
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
+30 -21
View File
@@ -24,6 +24,7 @@ const {
} = require("../../shared/amountDisplay");
const {
feeReserveWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
@@ -255,13 +256,18 @@ async function fillMaxAmount() {
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
);
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
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.");
return;
}
@@ -367,7 +373,28 @@ function init(_ctx) {
);
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
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,
));
}
// 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.");
return;
}
@@ -391,24 +418,6 @@ 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({
from: addr.address,
to: resolvedTo,
+7 -4
View File
@@ -76,11 +76,13 @@ function forgetOrigin(siteMap, origin) {
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// 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) {
forgetOrigin(state.allowedSites, origin);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists();
}
@@ -230,8 +232,8 @@ async function renderSiteLists() {
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
// Sites allowed without "Remember" are held only in session storage, which
// the background keeps, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
@@ -323,6 +325,7 @@ function init(ctx) {
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
showFlash("Switched to " + net.name + ".");
});
+6
View File
@@ -75,6 +75,12 @@ function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden";
// The bundled list holds Ethereum Mainnet contracts, which name no token
// on any other network.
$("settings-addtoken-common").classList.toggle(
"hidden",
state.networkId !== "mainnet",
);
renderTop10();
renderDropdown();
showView("settings-addtoken");
+23 -3
View File
@@ -41,6 +41,7 @@ const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
nodeError,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
@@ -72,15 +73,27 @@ const REQUEST_FIELDS = [
"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 {
constructor(message) {
constructor(message, code, data) {
super(message);
this.name = "ApprovalPrepareError";
if (code !== undefined) this.code = code;
if (data !== undefined) this.data = data;
}
}
function fail(message) {
return new ApprovalPrepareError(message);
function fail(message, code, data) {
return new ApprovalPrepareError(message, code, data);
}
function present(v) {
@@ -134,6 +147,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
INVALID_PARAMS_CODE,
);
}
request.gasLimit = txParams.gas;
@@ -144,6 +158,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
INVALID_PARAMS_CODE,
);
}
return request;
@@ -228,6 +243,9 @@ async function prepareApprovalTx(provider, from, txParams) {
);
} catch (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(
sentence(
"The transaction could not be prepared: " +
@@ -235,6 +253,8 @@ async function prepareApprovalTx(provider, from, txParams) {
e.message ||
"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.";
}
// 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
// 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
@@ -757,7 +766,7 @@ function failureTexts(err) {
for (const text of [err.shortMessage, err.message, err.reason]) {
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));
return texts;
}
@@ -891,6 +900,7 @@ module.exports = {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
nodeError,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
+56 -5
View File
@@ -174,9 +174,9 @@ function notify(message) {
// 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
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
function storageUnavailable(area, method) {
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) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
if (!storage) return storageUnavailable("local", "get");
return Promise.resolve(storage.get(keys));
}
@@ -198,7 +198,7 @@ function storageGet(keys) {
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
if (!storage) return storageUnavailable("local", "set");
return Promise.resolve(storage.set(items));
}
@@ -215,7 +215,55 @@ function storageSet(items) {
*/
function storageRemove(keys) {
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));
}
@@ -307,6 +355,9 @@ module.exports = {
notify,
runtimeApi,
sendMessage,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
storageGet,
storageLocal,
storageRemove,
+10
View File
@@ -38,6 +38,16 @@ function applyChainSwitchFields(s, newNetworkId) {
blockscoutUrl: s.blockscoutUrl,
};
const remembered = s.networkEndpoints[net.id] || {};
// Tracked tokens are kept per network the same way, because a contract
// address names a token on one network only. s.trackedTokens stays the
// active network's list, so nothing that reads it changes: the outgoing
// list is put away under its network, and the incoming network's list, or
// an empty one, takes its place.
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
+19
View File
@@ -23,6 +23,11 @@ const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
// trackedTokens above is the active network's list; this is what the
// others are restored from when the active network changes. See
// applyChainSwitchFields().
networkTrackedTokens: {},
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -289,6 +294,20 @@ function normalizePersisted(saved) {
// profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
// Each remembered list is floored like trackedTokens, because
// applyChainSwitchFields() puts it straight back onto trackedTokens when
// its network becomes the active one. A key that is not a network in
// networks.js is dropped, which also keeps an own "__proto__" key from
// reaching the plain assignment below.
out.networkTrackedTokens = {};
if (isRecord(saved.networkTrackedTokens)) {
for (const netId of Object.keys(saved.networkTrackedTokens)) {
if (!isKnownNetworkId(netId)) continue;
out.networkTrackedTokens[netId] = structuredClone(
tokenRefs(saved.networkTrackedTokens[netId]),
);
}
}
// The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below,
// so a value that is not a network in networks.js must never get that far
+12 -8
View File
@@ -42,9 +42,11 @@ const { log } = require("./log");
// and writes THIS object, never the proxy: the guard is for callers.
const rawState = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// mutates this map in place, and a spread copies the reference.
// Their own objects, not the ones DEFAULT_STATE holds:
// applyChainSwitchFields() mutates these maps in place, and a spread copies
// the reference.
networkEndpoints: {},
networkTrackedTokens: {},
currentView: null,
selectedWallet: null,
selectedAddress: null,
@@ -438,12 +440,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
//
// Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
// no in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
// mutation for a whole-field diff to collide with; `viewData` is this page's
// own UI scratch space, not data another page has any reason to share
// membership of.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
+37 -11
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
// 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 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",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
@@ -87,6 +88,27 @@ 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
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
@@ -112,9 +134,11 @@ function maxTokenAmount(tokenBalance) {
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// amount — human decimal string being sent. One isValidAmount()
// refuses, a negative value included, is an unusable amount
// 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
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
@@ -128,6 +152,7 @@ function maxTokenAmount(tokenBalance) {
function validateTransfer({
isErc20 = false,
amount,
tokenDecimals = null,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
@@ -135,17 +160,17 @@ function validateTransfer({
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
// An amount refused here never reaches the comparisons below: a negative
// one would pass every one of them, then die at encode time in
// parseEther().
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
codes.push(CODES.AMOUNT_INVALID);
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
// 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
@@ -195,6 +220,7 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
+1 -1
View File
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
// Forget every site permission held against the given addresses: the
// 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) {
for (const addr of addresses) {
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: [],
},
]);
});
});
+33 -12
View File
@@ -321,9 +321,10 @@ function loadBackground(options) {
require("../src/background/index");
// Send a message the way the browser would, and hand back whatever the
// handler passed to sendResponse.
function send(msg, sender) {
const sendResponse = jest.fn();
// handler passed to sendResponse. `onResponse`, when given, receives the
// answer when the handler sends it, as the sender's callback does.
function send(msg, sender, onResponse) {
const sendResponse = jest.fn(onResponse);
const kept = messageListener(msg, sender || {}, sendResponse);
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();
await settle();
// As ethers throws it: its own classification, and the node's
// JSON-RPC error under info.error.
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
info: { error: { code: -32000, message: "nonce too low" } },
}),
);
const answer = bg.send(
@@ -1182,8 +1186,10 @@ describe("a nonce collision is reported as a transaction that did not go out", (
retryable: false,
stage: "nonce",
});
// The wallet's sentence, with the node's own code.
expect(pending.result()).toEqual({
error: {
code: -32000,
message: expect.stringMatching(
/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({
error: {
code: -32603,
message: expect.stringMatching(/nonce had already been used/),
},
});
@@ -1323,6 +1330,7 @@ describe("what the approval is verified against", () => {
);
expect(pending.result()).toEqual({
error: {
code: -32603,
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(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({
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,
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
@@ -1611,7 +1627,9 @@ describe("what the approval is verified against", () => {
error: refusal,
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({
error: {
code: -32603,
message: expect.stringMatching(
/active address changed while this transaction was being prepared/,
),
@@ -1671,8 +1690,11 @@ describe("what the approval is verified against", () => {
const pending = bg.requestTx();
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({
error: {
code: -32603,
message: expect.stringMatching(
/could not be prepared.*execution reverted/,
),
@@ -2594,9 +2616,9 @@ async function siteAccounts(bg, origin) {
return sendResponse.mock.calls[0][0];
}
// A site connected without "Remember" is held only in the background's memory,
// keyed to the address it was connected to. Removing that address, or the
// wallet holding it, must end the connection as part of the removal itself.
// A site connected without "Remember" is held only in the background's session
// storage, keyed to the address it was connected to. Removing that address, or
// 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
// 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());
global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
bg.send(msg, bg.fromPopup, callback);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
+11 -4
View File
@@ -204,17 +204,21 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
// as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the
// extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
expect(after.networkTrackedTokens).toEqual({
mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
});
expect(after.theme).toBe("dark");
// The mainnet token is tracked on mainnet only: Sepolia starts with
// none of its own.
expect(after.trackedTokens).toEqual([]);
// The user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({
@@ -226,6 +230,9 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
expect(bg.persisted().trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
});
test("compares the requested chain against the stored one, not the default", async () => {
+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],
});
});
});
+2 -4
View File
@@ -3861,10 +3861,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
// Remembered, because the sign and transaction tests below all
// require the origin to still be authorized.
await popup.check("#approve-remember");
await clickAndClose(popup, "#btn-approve");
+22 -3
View File
@@ -22,6 +22,11 @@
// 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.
//
// 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
// 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
@@ -311,8 +316,8 @@ async function main() {
);
await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so
// the connection is stored rather than held by the worker.
// The site, connected through the real prompt without "Remember", so
// the connection is one that has to outlive the worker stopping below.
const dapp = await openPage(node.url);
await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
@@ -329,7 +334,7 @@ async function main() {
inPrompt(shows("view-approve-site")),
);
await inPrompt(
"document.getElementById('approve-remember').checked = true",
"document.getElementById('approve-remember').checked = false",
);
// The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {});
@@ -365,6 +370,20 @@ async function main() {
"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.
await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
+141
View File
@@ -0,0 +1,141 @@
// Adding the first wallet left no address active until the popup was reopened,
// so Send and Receive on Home only said "No active address selected."
// (https://git.eeqj.de/sneak/AutistMask/issues/512). Each test adds a wallet
// through one of the three import tabs and checks which address is active by
// opening Receive from Home in the same popup.
const { HDNodeWallet, Mnemonic, Wallet } = 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);
// The master key of PHRASE, so its first address is the phrase's.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const PHRASE_ADDRESS = HDNodeWallet.fromPhrase(PHRASE).address;
// Addresses 1 and 2 of PHRASE used, address 0 not: a scan that answers these
// replaces the wallet's list with one that leaves out its first address.
const account = HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0");
const used = [1, 2].map((index) => ({
address: account.deriveChild(index).address,
index,
}));
const TABS = [
{
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
address: PHRASE_ADDRESS,
},
{
tab: "tab-privkey",
field: "import-private-key",
secret: PRIVATE_KEY,
address: new Wallet(PRIVATE_KEY).address,
},
{
tab: "tab-xprv",
field: "import-xprv-key",
secret: XPRV,
address: PHRASE_ADDRESS,
},
];
// 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");
}
afterEach(() => {
cleanupPopup();
});
describe("the first wallet's first address is active right away", () => {
test.each(TABS)("added from $tab, Receive opens on it", async (entry) => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
await page.click("btn-main-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.node("receive-address-block").dataset.full).toBe(
entry.address,
);
});
});
describe("a scan that leaves out the first address", () => {
// The key tab runs no scan.
test.each(TABS.filter((t) => t.tab !== "tab-privkey"))(
"added from $tab, the scan's first address is active",
async (entry) => {
const page = await bootPopup(undefined);
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.storage.read("autistmask").activeAddress).toBe(
used[0].address,
);
expect(globalThis.chrome.runtime.sendMessage).toHaveBeenCalledWith({
type: "AUTISTMASK_ACTIVE_CHANGED",
});
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
used[0].address,
);
},
);
});
describe("adding a wallet while an address is active", () => {
test.each(TABS)(
"added from $tab, the active address is unchanged",
async (entry) => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
// The phrase and xprv scans leave out the new wallet's first
// address, which was never active here. The key tab runs no scan.
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-add-wallet-bottom");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.storage.read("autistmask").activeAddress).toBe(ADDRESS);
expect(
globalThis.chrome.runtime.sendMessage,
).not.toHaveBeenCalledWith({ type: "AUTISTMASK_ACTIVE_CHANGED" });
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
ADDRESS,
);
},
);
});
+24
View File
@@ -97,6 +97,7 @@ const {
normalizePersisted,
} = require("../src/shared/persistedState");
const { stateProblem } = require("../src/shared/stateSchema");
const { isKnownNetworkId } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const {
bootPopup,
@@ -193,6 +194,29 @@ const CONTRACT = [
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
},
{
field: "networkTrackedTokens",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { sepolia: "notalist" }],
floorOnly: [
[1, 2],
{ sepolia: [1, 2] },
{ mainnet: [null, {}, [TOKEN_ADDRESS]] },
{ notanetwork: [{ address: TOKEN_ADDRESS }] },
JSON.parse('{"__proto__":[{"address":"0xabc"}]}'),
],
// Each list is put straight back onto trackedTokens by
// applyChainSwitchFields() when its network becomes the active one, so
// it is floored the same way; a key that names no network is dropped.
holds: (v) =>
isRecord(v) &&
Object.getPrototypeOf(v) === Object.prototype &&
Object.keys(v).every(
(id) =>
isKnownNetworkId(id) &&
everyEntry(v[id], (t) => isRecord(t) && isText(t.address)),
),
},
{
field: "allowedSites",
kind: KIND.ENTRIES,
+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));
});
});
+213
View File
@@ -0,0 +1,213 @@
// 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,
}));
// What the confirmation screen asks the node for.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
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);
});
});
+6 -1
View File
@@ -265,12 +265,17 @@ async function bootPopup(stored, options) {
formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }),
}));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it.
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []),
scanForAddresses: jest.fn(async () => ({
addresses: [],
nextIndex: 1,
})),
}));
// filterTransactions() answers in the real one's shape: Home,
// AddressDetail and AddressToken read both fields, and a bare list makes
+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
// 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
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, 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 || (() => {});
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
const get = jest.fn(async (key) => {
tick();
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];
});
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 {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
local,
session: makeArea({}, tick),
get: local.get,
set: local.set,
remove: local.remove,
// What is stored in `storage.local`, cloned on the way out: a test can
// neither observe a later write through an object it read nor reach
// into the store by mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
+102
View File
@@ -0,0 +1,102 @@
// Tracked tokens are kept per network, and the two add-token screens offer the
// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum
// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523). Driven
// through the real popup over a stored profile; the same switch made by the
// background is in tests/coldWorkerChainSwitch.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto SettingsAddToken, on `networkId`.
function bootOnAddToken(networkId, extra) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "settings-addtoken",
...extra,
}),
);
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(boot, networkId) {
const select = boot.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await boot.settle();
}
describe.each([
["mainnet", false],
["sepolia", true],
])("on %s", (networkId, hidden) => {
test(`the common tokens are hidden: ${hidden}`, async () => {
const boot = await bootOnAddToken(networkId);
expect(boot.pageErrors).toEqual([]);
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
expect(boot.hidden("settings-addtoken-common")).toBe(hidden);
require("../src/popup/views/addToken").show();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.hidden("add-token-common")).toBe(hidden);
});
});
test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => {
const boot = await bootOnAddToken("sepolia", {
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const stored = () => boot.storage.read("autistmask");
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: SEPOLIA_TOKEN.symbol,
decimals: SEPOLIA_TOKEN.decimals,
name: SEPOLIA_TOKEN.name,
});
boot.node("settings-addtoken-address").value = TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
expect(lookupTokenInfo).toHaveBeenCalledWith(
TOKEN_ADDRESS,
expect.any(String),
"sepolia",
expect.anything(),
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
expect(onMainnet).toContain("DAI");
expect(onMainnet).not.toContain("TST");
await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI");
});
+6
View File
@@ -116,6 +116,12 @@ describe("transferAmountUnits", () => {
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", () => {
const messages = [];
for (const args of [
+65
View File
@@ -6,6 +6,7 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
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", () => {
const eth = (over) => ({
isErc20: false,
@@ -134,6 +178,17 @@ describe("validateTransfer, native ETH", () => {
).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", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
@@ -205,6 +260,16 @@ describe("validateTransfer, ERC-20", () => {
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", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);