Compare commits

...

6 Commits

Author SHA1 Message Date
clawbot
ad34fa8699 feat: password-gated recovery phrase display for HD wallets (closes #161)
Some checks failed
check / check (push) Has been cancelled
A user who created a wallet in AutistMask and did not write the phrase
down had no way to retrieve it. Adds a "Show recovery phrase" action on
the wallet row in Settings, next to the per-wallet actions that already
live there, mirroring the per-address private key export in structure,
password gate and warning treatment.

The screen displays the secret that owns every address in the wallet, so:

  - Only HD wallets are offered it. walletHasRecoveryPhrase() is an
    allowlist on type "hd", so the key and xprv types — which have no
    phrase at all — are excluded, as is any type added later.
  - Nothing is decrypted and nothing enters the page until
    decryptWithPassword accepts the password. A wrong password produces a
    full-sentence error and leaves the value node empty.
  - Leaving the screen wipes it by any route, not just "Back": views that
    hold a secret register a cleanup with showView() via onViewLeave(),
    which also covers the settings gear.
  - The phrase is never assigned to state, so it cannot be persisted, and
    the view is not in RESTORABLE_VIEWS — reopening the popup lands on
    Home. That set moves to src/popup/restorableViews.js so the exclusion
    can be asserted directly; the popup entry point cannot be required
    outside a browser.
  - The phrase cannot reach the logger: the view does not import
    src/shared/log.js, and the failed-decrypt path reports a fixed
    sentence rather than the caught error.

Tests: unit coverage for the type gate, the RESTORABLE_VIEWS exclusion
and the absence of any logger path; the DOM behaviour is driven against
the real popup in the e2e suite, which is where this repo tests views.
2026-08-11 13:00:59 +00:00
86cdea5e4e chore: repo policy compliance sweep — test rerun, frozen lockfile, documented targets (closes #166)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:57:51 +02:00
f271bcd7b4 fix: one transaction history row per value movement (closes #177)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:56:30 +02:00
93e3f6e4e2 fix: correct verify-build diagnostics and close two robustness gaps (closes #180)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:55:06 +02:00
9b957ffd69 fix: derive hasWallet from the wallet list on load (closes #195)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:51:22 +02:00
cf5f582be9 docs: correct three README claims contradicted by the code (closes #213)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:41:16 +02:00
22 changed files with 1427 additions and 116 deletions

View File

@@ -1,3 +1,6 @@
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
# build-info stamping and the Dockerfile runs `make build`, so excluding it
# would make every built extension report commitHash "unknown".
node_modules
.DS_Store
dist

View File

@@ -11,7 +11,7 @@ setup:
@script/setup
install:
@yarn install
@yarn install --frozen-lockfile
test:
@script/test

123
README.md
View File

@@ -31,10 +31,13 @@ list exists to detect symbol spoofing attacks and improve UX.
```bash
git clone https://git.eeqj.de/sneak/autistmask.git
cd autistmask
make install
make setup
make build
```
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
the lockfile and installs the git pre-commit hook.
Load the extension:
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
@@ -97,6 +100,19 @@ provide:
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook
The Makefile shims to those. It also carries a few targets that have no
`script/` counterpart and are Makefile-only conveniences:
- `make install``yarn install --frozen-lockfile` on its own, without the rest
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/`
- `make dev` — build in watch mode
## End-to-End Tests
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -107,8 +123,12 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen and
the transaction detail screen for an ERC-20 transfer. All outbound network is
It covers popup load, wallet creation through the UI, the Add Token screen, the
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
— which wallet types are offered it, that it holds nothing before the password
is accepted, that a wrong password reveals nothing, that leaving it by either
route wipes it — including a leave taken while the decrypt is still running —
and that reopening the popup does not land on it. All outbound network is
intercepted at the browser level and served from fixtures in
`tests/e2e/network.js`, so the run is deterministic and fully offline;
unrecognised outbound requests are reported as failures rather than silently
@@ -346,17 +366,32 @@ The core hierarchy is **Wallets → Addresses**:
address scan on import, but imported from an extended private key rather
than a recovery phrase. It therefore has no recovery phrase to display or
back up.
- An **address** holds ETH and any user-added ERC-20 tokens.
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled top-250 token list, is tracked by
the user, or has 1,000 or more holders; a token claiming a symbol from the
bundled list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
with zero balance" is on.
#### Navigation
The main view shows all addresses grouped by wallet, with ETH balances inline.
The user taps an address to see its detail view (full address, balance, tokens,
send/receive). Navigation is flat — every view has a "Back" or "Cancel" button
that returns to the previous context. No deep nesting, no tabs, no hamburger
menus.
send/receive). Navigation is a stack: each forward action pushes the current
screen, and every view has a "Back" or "Cancel" button that pops back to it (see
the Screen Map below). There is no hamburger menu and no persistent tab bar; the
Settings gear in the title bar is the only global control. Two screens carry an
in-screen control beyond that: AddWallet uses three tabs to select the import
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
behind a "···" menu.
### Screen Map
@@ -375,8 +410,11 @@ runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
screen, including ExportPrivKey, falls back to Home.
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front
of it.
#### Welcome (`welcome`)
@@ -393,7 +431,7 @@ screen, including ExportPrivKey, falls back to Home.
- **When**: At least one wallet exists. This is the root screen.
- **Elements**:
- Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and all tracked tokens of the active address
- "Total:" USD value across ETH and every token shown for the active address
- Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display
@@ -401,7 +439,7 @@ screen, including ExportPrivKey, falls back to Home.
button for HD and xprv wallets, then one block per address with "Address
N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, the address USD total, and a balance line for ETH and for
each tracked token
each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered
- "Add additional wallet..." link at bottom
@@ -454,8 +492,8 @@ screen, including ExportPrivKey, falls back to Home.
- ENS name (if resolved, bold above the address)
- Full address (color dot, etherscan link, tap to copy)
- USD total for address
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
Each balance row is clickable → **AddressToken**
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons and a "···" menu button
- "···" dropdown containing a single "Export Private Key" entry
- Transaction list (with ENS resolution for counterparties)
@@ -676,8 +714,9 @@ screen, including ExportPrivKey, falls back to Home.
- **When**: User tapped the Settings gear.
- **Elements**:
- "Back" button, "Settings" heading
- Wallets: one row per wallet with its name (tap to rename inline) and an
`[x]` delete button, plus a "+ Add wallet" button
- 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
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
@@ -702,6 +741,7 @@ screen, including ExportPrivKey, falls back to Home.
- **Transitions**:
- "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken**
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
@@ -709,6 +749,33 @@ screen, including ExportPrivKey, falls back to Home.
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
#### ShowRecoveryPhrase (`show-phrase`)
- **When**: User tapped `[recovery phrase]` on a wallet row in Settings. HD
wallets only: key and xprv wallets have no recovery phrase, so their rows do
not offer the action at all.
- **Elements**:
- "Back" button, "Recovery Phrase" heading
- Wallet name
- Warning box stating that anyone holding these words can take everything in
the wallet, from any device, without the password
- Error line
- Password input + "Reveal" button, shown until the password is accepted
- The recovery phrase itself, in full and click-to-copy, shown only after a
correct password and in place of the password prompt
- **Transitions**:
- "Reveal" (correct password) → the phrase replaces the password prompt (no
screen change)
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
screen change)
- "Back" → previous screen (Settings)
- **Secret handling**: nothing is decrypted or written into the page until the
password is accepted; the phrase is never stored in state, and it is wiped
from the page whenever the screen is left by any route, including the Settings
gear. A decrypt still running when the screen is left is discarded rather than
written. The screen is not restorable, so reopening the popup lands on Home
rather than back on the phrase.
#### DeleteWallet (`delete-wallet-confirm`)
- **When**: User tapped the `[x]` next to a wallet in Settings.
@@ -861,7 +928,7 @@ communicates with three external services to function as a wallet:
What the extension does NOT do:
- No analytics or telemetry services
- No token list APIs (user adds tokens manually by contract address)
- No token list APIs (the top-250 token list is bundled at build time)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer
@@ -984,7 +1051,8 @@ hardcoded test phrase.
- Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously
- View ETH balance per address
- View ERC-20 token balances (user adds token by contract address)
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
holders, and tokens the user adds by contract address)
- Send ETH to an address
- Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -1130,7 +1198,8 @@ Currently supported:
- Built in token swaps (use a DEX in the browser)
- Analytics, telemetry, or tracking of any kind
- Advertisements or promotions
- Obscure token list auto-discovery (user adds tokens manually)
- Obscure token list auto-discovery — nothing outside the bundled list, the
1,000-holder floor, and the tokens the user added by contract address
- We detect common/popular ERC20s in the basic case
- Fiat on/off ramps
- Extensive transaction decoding/parsing
@@ -1148,7 +1217,7 @@ Currently supported:
- [x] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation)
- [ ] Show wallet's recovery phrase (requires password)
- [x] Show wallet's recovery phrase (requires password)
### Transactions
@@ -1187,14 +1256,18 @@ This repository includes data files from third-party projects that are not
covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are:
| File | Source | Copyright | License |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
| File | Source | Copyright | License |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file.
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation; the vendored copy and
the runtime refresh both come from that upstream, whose URL is the
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
## Author

25
TODO.md
View File

@@ -44,16 +44,41 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
from the wallet row in Settings, wiped on leaving the screen and excluded from
the views the popup can reopen onto
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
message now states what is and is not proven, an unreadable bundle is
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
a plain ERC-20 transfer absorbed into its token row
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).

View File

@@ -29,6 +29,12 @@ function repoRelative(p) {
// reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build searches every file and symlink under dist/ for a
// marker, without filtering by extension, and hard-fails if it cannot walk the
// whole tree, so a bundle emitted under some other extension fails there as
// unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs)
.filter(([outFile, info]) => {
@@ -115,8 +121,17 @@ async function build() {
// build that never gets around to writing one cannot be verified against
// a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" },
);

View File

@@ -7,6 +7,7 @@
"private": true,
"scripts": {
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js",
"lint": "prettier --check .",
"fmt": "prettier --write .",

View File

@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Running tests..."
timeout 30 yarn run test 2>&1
timeout 30 yarn run test 2>&1 || {
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
}
main "$@"

View File

@@ -34,16 +34,51 @@ fail() {
exit 1
}
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "no marker" — that would blame the bundle for a permissions or I/O fault.
has_marker() {
grep -q -F "$1" "$2" 2>/dev/null
_hm_status=0
grep -q -F -e "$1" -- "$2" || _hm_status=$?
case "$_hm_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_hm_status reading $2, so the file could not be
searched and its DEBUG state was not checked at all. That is a permissions
or I/O fault on the artifact, not a change in the emitted output. Refusing
to report success."
;;
esac
}
# Does the manifest list the path $1, as a whole line? Same discipline as
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# manifest could not be read and is not an answer at all. Without this, an
# unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one.
is_listed() {
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_il_status reading $MANIFEST, so it could not be
searched and nothing was established about which bundles it lists. That is
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
to report success."
;;
esac
}
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time and the debug branch is live again.
# Neither means we are reading output we do not understand. Both are hard
# failures; neither is ever treated as absence of a problem.
# DEBUG stops being known at build time. Neither means we are reading output
# we do not understand. Both are hard failures; neither is ever treated as
# absence of a problem.
read_marker() {
_file="$1"
_on=no
@@ -52,9 +87,14 @@ read_marker() {
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
fail "$_file carries both debug markers, so the build-time DEBUG value
was never resolved and the debug branch is still live. Check that build.js
still defines __BUILD_DEBUG__."
fail "$_file carries both debug markers, so DEBUG was not resolved at
build time: the ternary in src/shared/constants.js survived into the
emitted output. This does not mean the debug branch is live in this
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
context, so DEBUG evaluates to false at runtime. It does mean the
release/debug distinction is no longer enforced at build time, and which
way that fallback happens to evaluate is then an accident a refactor can
flip. Check that build.js still defines __BUILD_DEBUG__."
fi
if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be
@@ -70,13 +110,43 @@ read_marker() {
}
# The manifest says which bundles must carry a marker. This says no other
# emitted bundle may carry one, which catches a manifest that has gone stale
# emitted file may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list.
#
# Deliberately unfiltered by extension. build.js selects manifest entries with
# an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every file under dist/ is searched, so build.js's filter is the only
# place the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive, so two things are enforced
# here rather than assumed:
#
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline
# ending in sort, so the sort is a separate step.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
check_unlisted_bundles() {
_listing="$(find dist -type f -name '*.js' | sort)"
_find_status=0
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
while read -r _file; do
[ -n "$_file" ] || continue
if grep -q -x -F "$_file" "$MANIFEST"; then
if is_listed "$_file"; then
continue
fi
if has_marker "$MARKER_ON" "$_file" ||
@@ -113,12 +183,18 @@ main() {
fail "$MANIFEST is empty, so no emitted bundle was found to contain
src/shared/constants.js. That is never correct, so it is a failure and not
a pass."
[ -r "$MANIFEST" ] ||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass."
count=0
while read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file"
[ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected."

View File

@@ -1098,6 +1098,52 @@
</button>
</div>
<!-- ============ SHOW RECOVERY PHRASE ============ -->
<div id="view-show-phrase" class="view hidden">
<button
id="btn-show-phrase-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-1">Recovery Phrase</h2>
<p class="text-xs mb-3" id="show-phrase-wallet-name"></p>
<div
class="text-xs mb-3 border border-border border-dashed p-2"
>
Anyone who has these words can take every coin and token in
this wallet, from any device, without your password. Never
type them into a website and never show them to anyone.
</div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="show-phrase-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="show-phrase-result" class="hidden">
<div
id="show-phrase-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden">
<button

View File

@@ -15,6 +15,10 @@ const {
clearViewStack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
// Views that can be fully re-rendered from persisted state. All others fall
// back to the nearest restorable parent; see the module for why the
// secret-bearing views are absent.
const { RESTORABLE_VIEWS } = require("./restorableViews");
const home = require("./views/home");
const welcome = require("./views/welcome");
@@ -99,21 +103,6 @@ const ctx = {
},
};
// Views that can be fully re-rendered from persisted state.
// All others fall back to the nearest restorable parent.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
function needsAddress(view) {
return (
view === "address" ||

View File

@@ -0,0 +1,29 @@
// Views the popup may reopen onto.
//
// The popup persists the current view so that reopening the toolbar popup
// lands the user back where they were. Only views that can be fully
// re-rendered from persisted state belong here; every other view falls back
// to the nearest restorable parent (src/popup/index.js restoreView()).
//
// A view that displays a secret must NEVER be listed. Restoring onto one
// would put a private key or a recovery phrase on screen with no password
// prompt in front of it, on a popup the user may have reopened by accident.
// That is why "export-privkey" and "show-phrase" are absent.
//
// Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
module.exports = { RESTORABLE_VIEWS };

View File

@@ -31,8 +31,20 @@ const VIEWS = [
"approve-tx",
"approve-sign",
"export-privkey",
"show-phrase",
];
// Cleanup callbacks for views that hold a secret in the DOM. The view
// registers one for itself and showView() runs it whenever that view is
// navigated away from, so the secret is wiped no matter which control
// caused the navigation — "Back", the settings gear, or a jump from
// anywhere else. A per-button clear would only cover the one path.
const viewLeaveHandlers = new Map();
function onViewLeave(name, fn) {
viewLeaveHandlers.set(name, fn);
}
function $(id) {
return document.getElementById(id);
}
@@ -50,6 +62,11 @@ function hideError(id) {
}
function showView(name) {
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
@@ -431,10 +448,12 @@ function flashCopyFeedback(el) {
}
module.exports = {
VIEWS,
$,
showError,
hideError,
showView,
onViewLeave,
updateDebugBanner,
setRenderMain,
pushCurrentView,

View File

@@ -14,6 +14,8 @@ const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
@@ -99,7 +101,14 @@ function renderWalletListSettings() {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
// Key and xprv wallets have no recovery phrase, so they are never
// offered the action at all.
if (walletHasRecoveryPhrase(wallet)) {
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
}
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</span>`;
html += `</div>`;
});
container.innerHTML = html;
@@ -111,6 +120,15 @@ function renderWalletListSettings() {
});
});
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
// No pushCurrentView() here: showPhrase.show() refuses
// non-HD wallets and pushes only when it navigates.
showPhrase.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
@@ -191,6 +209,7 @@ function renderSiteLists() {
function init(ctx) {
deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();

View File

@@ -0,0 +1,154 @@
// Recovery phrase display for HD wallets.
//
// The phrase is the secret that owns every address in the wallet, so it is
// handled under four rules:
//
// 1. Only an HD wallet reaches this screen (walletHasRecoveryPhrase).
// 2. Nothing is decrypted, and nothing is written into the DOM, until
// decryptWithPassword has accepted the password.
// 3. Leaving the screen by any path wipes it, via the onViewLeave hook,
// and a decrypt still in flight when that happens is discarded
// instead of written (revealGeneration).
// 4. The phrase never reaches the logger. This module deliberately does
// not import src/shared/log.js, and the failed-decrypt path reports a
// fixed sentence rather than the caught error.
//
// The phrase is also never assigned to `state`, so it cannot be persisted
// to extension storage, and "show-phrase" is excluded from RESTORABLE_VIEWS
// so the popup can never reopen onto it.
const {
$,
showView,
showFlash,
flashCopyFeedback,
goBack,
onViewLeave,
pushCurrentView,
} = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const VIEW = "show-phrase";
let walletIndex = null;
// Bumped by every clear(), which is what leaving the screen runs. reveal()
// captures it before awaiting the decrypt and refuses to touch the DOM if
// it has moved: a decrypt still in flight when the screen is left would
// otherwise write the phrase *after* the wipe, with nothing scheduled to
// wipe it again, leaving it in the hidden view for the life of the popup.
let revealGeneration = 0;
// True only if the reveal that captured `generation` is still the live one:
// the screen has not been left, cleared, or re-entered for another wallet
// since it started.
function isCurrentReveal(generation) {
return (
generation === revealGeneration &&
walletIndex !== null &&
state.currentView === VIEW
);
}
function fail(message) {
$("show-phrase-flash").textContent = message;
$("show-phrase-flash").style.visibility = "visible";
}
// Wipe every trace of the phrase and drop the wallet selection. Safe to
// call when nothing was ever revealed, and safe to call twice.
function clear() {
walletIndex = null;
revealGeneration += 1;
$("show-phrase-value").textContent = "";
$("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
}
function show(walletIdx) {
const wallet = state.wallets[walletIdx];
if (!walletHasRecoveryPhrase(wallet)) {
showFlash("This wallet does not have a recovery phrase.");
return;
}
clear();
walletIndex = walletIdx;
$("show-phrase-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
// Pushed here rather than by the caller: this function can return
// without navigating, and a push that happened anyway would leave an
// entry on the stack that no screen transition matches.
pushCurrentView();
showView(VIEW);
}
async function reveal() {
const password = $("show-phrase-password").value;
if (!password) {
fail("Please enter your password.");
return;
}
if (walletIndex === null) {
fail("No wallet is selected.");
return;
}
const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) {
fail("This wallet does not have a recovery phrase.");
return;
}
const btn = $("btn-show-phrase-reveal");
btn.disabled = true;
btn.classList.add("text-muted");
const generation = revealGeneration;
try {
const phrase = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
// The only suspension point in this view, and the only place a
// secret is written: if the screen was left while the decrypt ran,
// the wipe has already happened and this write must not land.
if (!isCurrentReveal(generation)) return;
$("show-phrase-password").value = "";
$("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface.
fail("That password is not correct. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
}
function init() {
onViewLeave(VIEW, clear);
$("btn-show-phrase-back").addEventListener("click", () => {
goBack();
});
$("btn-show-phrase-reveal").addEventListener("click", reveal);
$("show-phrase-value").addEventListener("click", () => {
const phrase = $("show-phrase-value").textContent;
if (!phrase) return;
navigator.clipboard.writeText(phrase);
showFlash("Copied!");
flashCopyFeedback($("show-phrase-value"));
});
}
module.exports = { init, show };

View File

@@ -84,8 +84,11 @@ async function loadState() {
const result = await storageApi.get("autistmask");
if (result.autistmask) {
const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;

View File

@@ -113,6 +113,85 @@ function parseTokenTransfer(tt, addrLower) {
};
}
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase();
@@ -145,53 +224,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txsByHash = new Map();
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length);
return result;
@@ -265,4 +302,8 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud };
}
module.exports = { fetchRecentTransactions, filterTransactions };
module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};

View File

@@ -74,6 +74,15 @@ function isValidMnemonic(mnemonic) {
return Mnemonic.isValidMnemonic(mnemonic);
}
// Only an HD wallet has a recovery phrase. A "key" wallet holds a bare
// private key and an "xprv" wallet an extended private key; neither can be
// turned back into words, so neither may ever be offered the phrase display.
// Written as an allowlist on purpose: a wallet type added later is excluded
// until someone decides otherwise.
function walletHasRecoveryPhrase(walletData) {
return !!walletData && walletData.type === "hd";
}
module.exports = {
generateMnemonic,
deriveAddressFromXpub,
@@ -83,4 +92,5 @@ module.exports = {
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,
walletHasRecoveryPhrase,
};

View File

@@ -255,6 +255,11 @@ async function openPopup(ctx, popupUrl) {
// Full wallet creation through the real UI: BIP-39 generation, libsodium
// vault encryption and extension storage persistence, for real.
//
// Returns the recovery phrase it generated. Tests that assert on a secret
// need the real value — checking for "some 12 words" would pass against the
// wrong wallet's phrase, and checking for nothing at all would pass against
// a screen that shows the phrase it was supposed to hide.
async function createWallet(page) {
await page.click("#btn-welcome-add");
await visible(page, "#view-add-wallet");
@@ -263,10 +268,12 @@ async function createWallet(page) {
const el = document.getElementById("wallet-mnemonic");
return el && el.value.trim().split(/\s+/).length >= 12;
});
const phrase = (await page.inputValue("#wallet-mnemonic")).trim();
await page.fill("#add-wallet-password", PASSWORD);
await page.fill("#add-wallet-password-confirm", PASSWORD);
await page.click("#btn-add-wallet-confirm");
await visible(page, "#view-main", 60000);
return phrase;
}
// Reach the address detail screen from wherever the popup restored to.
@@ -281,6 +288,7 @@ async function openAddressDetail(page) {
}
module.exports = {
PASSWORD,
createWallet,
launch,
openAddressDetail,

View File

@@ -10,6 +10,7 @@
"use strict";
const {
PASSWORD,
createWallet,
launch,
openAddressDetail,
@@ -34,6 +35,10 @@ function assert(cond, message) {
if (!cond) throw new Error(message);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout(promise, name) {
let timer;
const timeout = new Promise((_, reject) => {
@@ -56,7 +61,11 @@ test("popup loads and reaches the welcome view", async (env) => {
});
test("wallet creation through the UI reaches the main view", async (env) => {
await createWallet(env.page);
env.phrase = await createWallet(env.page);
assert(
env.phrase.split(/\s+/).length >= 12,
"wallet creation did not yield a recovery phrase",
);
const addrCount = await env.page
.locator("#wallet-list .btn-addr-info")
.count();
@@ -117,6 +126,256 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
assert(dots > 0, "token contract row rendered without its colour dot");
});
// -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it.
async function openSettings(page) {
if (!(await page.isVisible("#view-settings"))) {
await page.click("#btn-settings");
}
await visible(page, "#view-settings");
}
// Everything the recovery phrase screen is holding, read straight out of
// the DOM whether or not that screen is the one on top. Reading it while it
// is hidden is the point: "cleared on leave" means the node is empty, not
// merely off-screen.
async function phraseScreenState(page) {
return page.evaluate(() => ({
value: document.getElementById("show-phrase-value").textContent,
error: document.getElementById("show-phrase-flash").textContent,
html: document.getElementById("view-show-phrase").innerHTML,
resultHidden: document
.getElementById("show-phrase-result")
.classList.contains("hidden"),
viewHidden: document
.getElementById("view-show-phrase")
.classList.contains("hidden"),
}));
}
async function openPhraseScreen(page) {
await openSettings(page);
await page.click("#settings-wallet-list .btn-show-phrase");
await visible(page, "#view-show-phrase");
}
async function revealPhrase(page) {
await page.fill("#show-phrase-password", PASSWORD);
await page.click("#btn-show-phrase-reveal");
await visible(page, "#show-phrase-result", 60000);
}
function assertWiped(st, phrase, where) {
assert(st.value === "", "phrase still in the DOM " + where);
assert(st.resultHidden, "result section still shown " + where);
assert(
!st.html.includes(phrase),
"the recovery phrase is still somewhere in the screen markup " + where,
);
}
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
await openSettings(env.page);
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
assert(
offered === 1,
"the HD wallet was not offered the recovery phrase action",
);
});
// The other half of the gate, against the real UI: a wallet holding a bare
// private key has no phrase to show, so no row of it may offer the action.
// The key is generated here rather than committed — the repo holds no
// private keys, test ones included.
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
const { Wallet } = require("ethers");
await openSettings(env.page);
await env.page.click("#btn-main-add-wallet");
await visible(env.page, "#view-add-wallet");
await env.page.click("#tab-privkey");
await env.page.fill(
"#import-private-key",
Wallet.createRandom().privateKey,
);
await env.page.fill("#add-wallet-password", PASSWORD);
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
await env.page.click("#btn-add-wallet-confirm");
await visible(env.page, "#view-main", 60000);
await openSettings(env.page);
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
assert(wallets === 2, "expected two wallet rows, got " + wallets);
assert(
offered === 1,
"the key wallet was offered the recovery phrase action",
);
});
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
await openPhraseScreen(env.page);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "before any password was entered");
const passwordShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(passwordShown, "the password prompt is not shown");
});
test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.fill("#show-phrase-password", "not-the-password");
await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction(
() =>
document.getElementById("show-phrase-flash").textContent.length > 0,
null,
{ timeout: 60000 },
);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after a wrong password");
assert(
/^[A-Z].*\.$/.test(st.error.trim()),
"the wrong-password error is not a full sentence: " +
JSON.stringify(st.error),
);
});
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
const console_ = [];
const listener = (msg) => console_.push(msg.text());
env.page.on("console", listener);
try {
await revealPhrase(env.page);
const st = await phraseScreenState(env.page);
assert(
st.value === env.phrase,
"the displayed phrase is not the wallet's phrase, verbatim",
);
const promptShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(!promptShown, "the password prompt is still shown after unlock");
// Full Identifiers Policy: shown whole, and copyable.
const title = await env.page.getAttribute(
"#show-phrase-value",
"title",
);
assert(title === "Click to copy", "the phrase is not click-to-copy");
const leaked = console_.filter((line) => line.includes(env.phrase));
assert(
leaked.length === 0,
"the recovery phrase reached the console: " +
JSON.stringify(leaked),
);
} finally {
env.page.off("console", listener);
}
});
test('"Back" wipes the revealed phrase (#161)', async (env) => {
await env.page.click("#btn-show-phrase-back");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after Back");
});
// The settings gear leaves the screen without touching its Back button. A
// clear wired only to Back would pass the test above and leak here.
test("leaving by the settings gear wipes it too (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after leaving via the settings gear");
});
// The same leave, but taken while the decrypt is still running. Both
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
// handler up to the await, the gear then runs the leave — and the wipe with
// it — to completion, and the decrypt's continuation resumes afterwards.
// Without a liveness check that continuation writes the phrase into the
// hidden screen after the wipe, and nothing is left to wipe it again.
//
// A human cannot produce this interleaving by hand once libsodium's wasm is
// warm, because crypto_pwhash is synchronous and the only suspension point
// is a microtask; the window a user can actually hit is a still-pending
// sodium.ready on the first vault use of a page load. Forcing it here is
// the only way to test the guard deterministically.
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
await openPhraseScreen(env.page);
await env.page.fill("#show-phrase-password", PASSWORD);
await env.page.evaluate(() => {
document.getElementById("btn-show-phrase-reveal").click();
document.getElementById("btn-settings").click();
});
await visible(env.page, "#view-settings");
// The Reveal button is disabled for exactly the duration of the
// decrypt and re-enabled in the same continuation that would have
// written the phrase, so waiting for it to come back is a precise
// "the decrypt has settled and its handler has finished" signal
// rather than a guess at a duration.
await env.page.waitForFunction(
() => !document.getElementById("btn-show-phrase-reveal").disabled,
null,
{ timeout: 60000 },
);
await sleep(2000);
const st = await phraseScreenState(env.page);
// Printed on every run, pass or fail: "the phrase is not there" is
// worth more as a measurement than as a silent assertion, and the
// same line read from a build without the guard is what this test
// exists to prevent.
console.log(
"# probe: len=" +
st.value.length +
" equalsPhrase=" +
(st.value === env.phrase) +
" resultHidden=" +
st.resultHidden +
" viewHidden=" +
st.viewHidden,
);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after leaving mid-decrypt");
});
// Closing and reopening the page rather than reloading it: that is what
// the toolbar popup actually does, and the persisted currentView is
// "show-phrase" at the moment it happens, which is precisely the state
// RESTORABLE_VIEWS has to refuse.
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-main");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
assertWiped(st, env.phrase, "after reopening the popup");
});
// ---------------------------------------------------------------- runner
async function main() {
@@ -154,6 +413,9 @@ async function main() {
popupUrl: session.popupUrl,
routeOpts,
page: null,
// The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape.
phrase: null,
};
// Attribution of collected errors is total. session.errors has no

94
tests/showPhrase.test.js Normal file
View File

@@ -0,0 +1,94 @@
// Tests for the recovery phrase display (issue #161).
//
// These cover the parts that do not need a DOM: which wallet types may be
// offered the action at all, the exclusion of the screen from the set of
// views the popup may reopen onto, and the absence of any path from this
// module to the logger. The DOM behaviour it guards — nothing rendered
// before the password is accepted, a wrong password revealing nothing, and
// the wipe on leaving — is driven against the real popup in a real browser
// by tests/e2e/run.js, which is where every other view behaviour is tested.
const fs = require("fs");
const path = require("path");
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const SHOW_PHRASE_VIEW = "show-phrase";
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
function loadHelpers() {
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
return require("../src/popup/views/helpers");
}
describe("which wallets have a recovery phrase", () => {
test("an HD wallet does", () => {
expect(walletHasRecoveryPhrase({ type: "hd" })).toBe(true);
});
// A key wallet holds a bare private key and an xprv wallet an extended
// private key. Neither can be turned back into words, so neither may be
// offered the action.
test("a key wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "key" })).toBe(false);
});
test("an xprv wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "xprv" })).toBe(false);
});
test("an unknown or missing wallet type does not", () => {
expect(walletHasRecoveryPhrase({ type: "something-new" })).toBe(false);
expect(walletHasRecoveryPhrase({})).toBe(false);
expect(walletHasRecoveryPhrase(undefined)).toBe(false);
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto a secret screen would put the phrase on screen with no
// password prompt in front of it, on a popup the user may have reopened
// by accident.
test("the recovery phrase screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(SHOW_PHRASE_VIEW)).toBe(false);
});
test("the private key export screen is not restorable either", () => {
expect(RESTORABLE_VIEWS.has("export-privkey")).toBe(false);
});
test("the recovery phrase screen is still a registered view", () => {
const { VIEWS } = loadHelpers();
expect(VIEWS).toContain(SHOW_PHRASE_VIEW);
});
// Guards the other direction: a restorable name that is not a real view
// would leave restoreView() showing nothing at all.
test("every restorable view is a registered view", () => {
const { VIEWS } = loadHelpers();
for (const view of RESTORABLE_VIEWS) {
expect(VIEWS).toContain(view);
}
});
});
describe("the phrase cannot reach the logger", () => {
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
"utf8",
);
// The decrypted phrase only ever lives in a local and in the DOM node
// that displays it. The module has no logger to hand it to, and this
// pins that: src/shared/log.js writes to the console, and a console
// record of a recovery phrase outlives the popup.
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});

104
tests/state.test.js Normal file
View File

@@ -0,0 +1,104 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {
delete global.chrome;
});
describe("loadState hasWallet reconciliation", () => {
// A profile that deleted its last wallet on a build predating the write
// path fix keeps hasWallet: true forever. It must load as no wallet, which
// is what sends the popup to the welcome view.
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true });
await mod.loadState();
expect(mod.state.wallets).toEqual([]);
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
});
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("absent hasWallet with wallets present loads as having a wallet", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("consistent stored states are preserved", async () => {
const withWallet = loadModuleWith({
hasWallet: true,
wallets: oneWallet(),
});
await withWallet.mod.loadState();
expect(withWallet.mod.state.hasWallet).toBe(true);
const without = loadModuleWith({ hasWallet: false, wallets: [] });
await without.mod.loadState();
expect(without.mod.state.hasWallet).toBe(false);
});
test("empty storage leaves the default no-wallet state", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
expect(mod.state.wallets).toEqual([]);
});
// The correction is derived on every load rather than written back, so a
// load never has a storage side effect.
test("loadState does not write to storage", async () => {
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(set).not.toHaveBeenCalled();
});
// Deriving must not disturb the rest of the load.
test("other persisted fields still load", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
networkId: "sepolia",
theme: "dark",
activeAddress: ADDRESS,
});
await mod.loadState();
expect(mod.state.networkId).toBe("sepolia");
expect(mod.state.theme).toBe("dark");
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});

View File

@@ -36,6 +36,7 @@ global.chrome = { storage: { local: {} } };
const {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
} = require("../src/shared/transactions");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { debugFetch } = require("../src/shared/log");
@@ -685,6 +686,339 @@ describe("legitimate transactions are never filtered", () => {
});
});
// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes. One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------
// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "",
exactValue: "",
rawAmount: "",
rawUnit: "",
valueGwei: 0,
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
...overrides,
});
}
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
method: "transfer",
...overrides,
});
}
describe("mergeTransactions: one row per value movement", () => {
const HASH = "0x" + "d".repeat(64);
const OTHER_HASH = "0x" + "e".repeat(64);
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
test("a plain ERC-20 transfer yields one row, the token row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
});
test("an ETH-only transfer keeps its row unchanged", () => {
const merged = mergeTransactions([legitimateEthSend()], []);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual(legitimateEthSend());
});
test("a genuine zero-value native transaction is still displayed", () => {
const zero = nativeTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([zero], []);
expect(merged).toEqual([zero]);
});
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
const zero = erc20CallTx({ hash: HASH });
const unrelated = tokenTx({ hash: OTHER_HASH });
const merged = mergeTransactions([zero], [unrelated]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.hash).sort()).toEqual(
[HASH, OTHER_HASH].sort(),
);
});
test("a native transaction that moved ETH keeps its row beside the token row", () => {
// An undecoded call (no method name) carrying ETH that also emitted
// a token transfer: two real movements, so two rows.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
valueGwei: 250000000,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
});
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
// delete this row and lose a real ETH movement, so the decision is made
// on rawAmount as a BigInt.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.0000",
exactValue: "0.0000000005",
rawAmount: "500000000",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
"500000000",
);
});
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const sentLeg = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const receivedLeg = tokenTx({
hash: HASH,
from: ROUTER,
to: VICTIM,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("WETH");
expect(merged[0].exactValue).toBe("0.25");
// The user's own address and the contract called are preserved.
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(ROUTER);
expect(merged[0].directionLabel).toBe("Swap");
});
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const firstSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const secondSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [firstSent, secondSent]);
expect(merged).toHaveLength(1);
// With no received leg the display amount comes from the first sent
// leg, and a later sent leg does not overwrite it.
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
expect(merged[0].holders).toBe(3500000);
});
test("a contract call carrying ETH plus a token transfer stays one row", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "swapExactETHForTokens",
valueGwei: 250000000,
});
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [received]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
// The ETH leg is still visible as the row's native quantity.
expect(merged[0].valueGwei).toBe(250000000);
});
test("an approve keeps its row and survives the filters", () => {
const approve = contractCallTx({ hash: HASH });
const merged = mergeTransactions([approve], []);
expect(merged).toEqual([approve]);
expect(filterTransactions(merged, filters()).transactions).toEqual([
approve,
]);
});
test("a contract creation keeps its row", () => {
const creation = nativeTx({
hash: HASH,
from: VICTIM,
to: "",
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([creation], [])).toEqual([creation]);
});
test("a native self-send keeps its single row", () => {
const selfSend = nativeTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
});
test("a token self-send yields one row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(VICTIM);
});
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
const native = erc20CallTx({ hash: HASH });
const usdc = tokenTx({ hash: HASH });
const weth = tokenTx({
hash: HASH,
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [usdc, weth]);
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
test("rows are sorted by block number, newest first", () => {
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
const merged = mergeTransactions([older, newer], []);
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
});
test("the entries handed in are never mutated", () => {
const native = contractCallTx({ hash: HASH, method: "execute" });
const token = tokenTx({ hash: HASH });
const before = JSON.stringify([native, token]);
mergeTransactions([native], [token]);
expect(JSON.stringify([native, token])).toBe(before);
});
});
// ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers. (The cross-address merge Home performs lives in
@@ -886,13 +1220,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
// Documents current behaviour: for a plain ERC-20 transfer the method is
// "transfer", so parseTx does not mark the entry as a contract call in
// the display sense and the merge loop does not consolidate the token
// transfer into it. The result is two entries for one transaction: a
// zero-value native row and the real token row. The zero-value row also
// escapes dust filtering because isContractCall is true.
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
// Regression guard for the duplicate-row bug: for a plain ERC-20
// transfer the method is "transfer", so parseTx does not mark the entry
// as a contract call in the display sense. The native side of that
// transaction moved no ETH and is represented by the token row, so it
// must not survive the merge as a second, zero-value row.
test("a plain ERC-20 transfer produces exactly one entry", async () => {
const hash = "0x" + "5".repeat(64);
respondWith(
[
@@ -925,14 +1258,15 @@ describe("fetchRecentTransactions merge and dedup", () => {
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(2);
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
const nativeRow = txs.find((t) => t.symbol === "ETH");
expect(nativeRow.exactValue).toBe("0.0");
expect(nativeRow.isContractCall).toBe(true);
// And the zero-value row is not removed by the dust filter.
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0");
expect(txs[0].direction).toBe("sent");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
// The surviving row is the token row, and the filters keep it.
const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(2);
expect(kept).toHaveLength(1);
expect(kept[0].symbol).toBe("USDC");
});
test("entries are sorted by block number descending and capped at count", async () => {