From e8ad8325c85db68955a699f3b6919cf3ba0d3dbf Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 10 Aug 2026 15:49:32 +0200 Subject: [PATCH 01/44] test: containerized Chrome end-to-end harness that drives the real popup (closes #181) Runs the real popup in a pinned containerized Chrome and fails on any uncaught page error or console.error. Also fixes the two defects it caught: the missing showView import in addToken.js and the missing addressDotHtml import in transactionDetail.js. closes #150 closes #151 --- Makefile | 6 +- README.md | 62 +++++ TODO.md | 21 +- package.json | 1 + script/test-e2e | 64 +++++ src/popup/views/addToken.js | 2 +- src/popup/views/transactionDetail.js | 1 + tests/e2e/harness.js | 289 +++++++++++++++++++++++ tests/e2e/network.js | 334 +++++++++++++++++++++++++++ tests/e2e/run.js | 264 +++++++++++++++++++++ yarn.lock | 5 + 11 files changed, 1044 insertions(+), 5 deletions(-) create mode 100755 script/test-e2e create mode 100644 tests/e2e/harness.js create mode 100644 tests/e2e/network.js create mode 100644 tests/e2e/run.js diff --git a/Makefile b/Makefile index 33ab6d0..6a3f3be 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build build-debug clean dev +.PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build build-debug clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -16,6 +16,10 @@ install: test: @script/test +# Browser end-to-end suite. Requires docker; not part of check. +test-e2e: + @script/test-e2e + lint: @script/lint diff --git a/README.md b/README.md index a4023da..0e8756d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ provide: git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) - `script/test` — run the test suite (jest) +- `script/test-e2e` — run the browser end-to-end suite (docker required; see + [End-to-End Tests](#end-to-end-tests)) - `script/lint` — run the linter - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) @@ -82,6 +84,66 @@ provide: - `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/install-precommit` — install the git pre-commit hook +## End-to-End Tests + +`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real +Chrome**, loaded as an unpacked MV3 extension inside a pinned +`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`; +docker is required and the suite fails loudly rather than skipping if it is +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 +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 +allowed. + +That reporting has one bound worth knowing. Observation ends when the browser +context is torn down, and nothing can watch traffic after that, so the run keeps +collecting for a fixed grace period after the last test returns +(`TRAILING_WATCH_MS` in `tests/e2e/run.js`, currently 1500ms) and then closes +the context. A request whose _first_ dispatch falls after that window is never +seen at all and cannot fail the run. In practice a request a test fires without +awaiting reaches the route handler about 10ms later, and anything on a repeating +timer gets observed on an earlier tick during the ~20s suite — but a one-shot +call deliberately deferred past the window will escape. + +That interception covers the MV3 background service worker as well as the popup +page, which it does not by default — `script/test-e2e` sets +`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is +experimental, the harness does not take it on trust. At launch it waits for the +background worker's **own** startup request — the phishing blocklist fetch that +`src/background/index.js` issues unconditionally — to arrive in the route +handler, and aborts the entire suite if none does within 30 seconds +(`tests/e2e/harness.js`). The check is passive on purpose: a synthetic probe +fetched from inside the worker via `worker.evaluate()` was tried first and +rejected, because evaluating in an extension service worker that early kills the +worker outright, destroying the thing being measured. Observing traffic the +extension already generates perturbs nothing. Losing the race fails closed — the +suite refuses to run rather than passing quietly. + +As defence in depth, Chrome is also started with +`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past +the route handler could not resolve a host at all. That only bounds the damage; +detecting escaping traffic remains the canary's job. To see what is actually +being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is +printed, tagged `[sw]` or `[page]`. + +**Any uncaught page error or `console.error` fails the run.** That is the point: +a `ReferenceError` from a used-but-not-imported identifier is invisible to +`make check` (`script/lint` is only `prettier --check`) but fatal in a browser, +and this suite exists because exactly that class of bug shipped twice. + +`make test-e2e` is deliberately **not** part of `make check` or `make test`. +`REPO_POLICIES.md` caps `make test` at 20 seconds and a browser suite does not +fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot pick it up +either. It is also not wired into the Gitea workflow yet — docker-in-docker in +CI is a separate question. Run it locally before changing anything under +`src/popup/views/`. + ## Rationale Common popular EVM wallets have become bloated with swap UIs, portfolio diff --git a/TODO.md b/TODO.md index b94902f..0f0f652 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,8 @@ other branch is in flight: the settings About well landed as #145 on 2026-07-26 and scripts-to-rule-them-all landed as #148, so the `scripts/` directory question is resolved. Full policy file set present. `make check` verified passing on `main` at `23aeae4` on 2026-08-09. The 1.0.0 backlog is filed as -#149-#168. +#149-#168. A real-browser end-to-end suite (`make test-e2e`) now sits alongside +`make check`, which cannot see a runtime `ReferenceError` in a popup view. # Next Step @@ -27,6 +28,13 @@ review. # Completed Steps +- 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` / + `script/test-e2e`) driving the real popup with all network intercepted, plus + the two used-but-not-imported crashes it caught: AddToken unreachable (#150) + and TransactionDetail broken for every ERC-20 transfer (#151). Harness + demonstrated failing before the fixes and passing after (#181). Interception + covers the MV3 background service worker, not just the popup page, and a + launch-time canary aborts the suite if worker traffic starts escaping. - 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog (#149-#168). - 2026-07-26: About well in settings with build info, repo link and the version @@ -52,10 +60,17 @@ review. # Future Steps -- Fix the two `ReferenceError` crashes that make whole screens unreachable: - AddToken (#150) and TransactionDetail for every ERC-20 transfer (#151). - Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only and cannot catch undefined identifiers, which is how #150 and #151 shipped. +- Decide the libsodium backend that actually ships (#182) and delete the single + allowlist entry it owns in `tests/e2e/harness.js`. +- Extend the end-to-end suite to the dApp approval signing path (EIP-1193 + through the real content script, background worker and approval popup). That + path needs a CDP-based route to background-worker console output first: + Playwright exposes no error event for service workers, so an uncaught + exception in the worker cannot fail the run today (worker network traffic is + already covered). Decide separately whether docker-in-docker makes + `make test-e2e` runnable in the Gitea workflow. - Make the Firefox target functional: Chrome callback APIs are used against the promise-only `browser` namespace (#153). - Send and transaction-flow correctness: gas fee excluded from the diff --git a/package.json b/package.json index f6aea86..c7ecbdd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tailwindcss/cli": "^4.2.1", "esbuild": "^0.27.3", "jest": "^30.2.0", + "playwright-core": "1.56.0", "prettier": "^3.8.1", "tailwindcss": "^4.2.1" }, diff --git a/script/test-e2e b/script/test-e2e new file mode 100755 index 0000000..ece9a5d --- /dev/null +++ b/script/test-e2e @@ -0,0 +1,64 @@ +#!/bin/sh +# script/test-e2e: build the extension and drive the real popup in a real +# Chromium inside a pinned container. Our own extension to +# scripts-to-rule-them-all. +# +# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md +# caps make test at 20 seconds and a browser suite does not fit. Run it +# yourself before touching popup views; it is the only check that can see +# a used-but-not-imported identifier blow up at runtime. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09 +# +# The playwright-core devDependency is pinned to the matching Playwright +# version (1.56.0) and the two must be bumped together: the browsers ship +# inside this image, and playwright-core looks for the exact browser +# revision its own version expects. A mismatch fails at launch. +IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2" + +main() { + cd "$ROOT" + + if ! command -v docker >/dev/null 2>&1; then + echo "test-e2e: docker is required to run the e2e suite" >&2 + exit 1 + fi + + echo "Building extension for e2e..." + yarn run build 2>&1 + + echo "Running e2e suite in the pinned Playwright container..." + # --ipc=host: Chromium's shared-memory needs more than the default + # 64MB /dev/shm or renderers crash. + # --user: keep files the suite touches owned by the caller, not root. + # HOME=/tmp: the mapped uid has no home directory in the image. + # PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it, + # ctx.route() intercepts page requests only, and every fetch made by + # the MV3 background service worker — including the phishing + # blocklist fetch that src/background/index.js issues at worker + # startup — goes to the real internet. The flag is experimental and + # Playwright may drop or rename it. It cannot break silently: the + # harness probes service-worker interception at launch and aborts + # the whole suite if it is not in effect (see the interception + # canary in tests/e2e/harness.js). If a future Playwright removes + # the flag, that probe is what will fail, and the fix is either a + # replacement mechanism or an honest downgrade of the isolation + # claim in tests/e2e/network.js and README.md — not deleting the + # probe. The image is pinned by digest, so this can only ever bite + # on a deliberate bump. + docker run --rm \ + --ipc=host \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \ + -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ + -v "$ROOT:/work" \ + -w /work \ + "$IMAGE" \ + node tests/e2e/run.js +} + +main "$@" diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js index 88c7b36..63b75ed 100644 --- a/src/popup/views/addToken.js +++ b/src/popup/views/addToken.js @@ -1,4 +1,4 @@ -const { $, showFlash, goBack } = require("./helpers"); +const { $, showView, showFlash, goBack } = require("./helpers"); const { getTopTokens } = require("../../shared/tokenList"); const { state, saveState } = require("../../shared/state"); const { lookupTokenInfo } = require("../../shared/balances"); diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 2cb4437..5922ddb 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -7,6 +7,7 @@ const { showFlash, flashCopyFeedback, addressTitle, + addressDotHtml, escapeHtml, isoDate, timeAgo, diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js new file mode 100644 index 0000000..f51fe78 --- /dev/null +++ b/tests/e2e/harness.js @@ -0,0 +1,289 @@ +// End-to-end harness: launches a real Chromium with the unpacked MV3 +// build loaded, collects every uncaught page error and console.error, and +// exposes the popup flows the tests drive. +// +// This runs inside the pinned Playwright container; see script/test-e2e. +// It is deliberately NOT part of make check — REPO_POLICIES.md caps +// make test at 20 seconds and a browser suite does not fit. + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { chromium } = require("playwright-core"); +const { installNetworkStubs } = require("./network"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome"); + +// Page errors that are known, tracked, and deliberately tolerated. Every +// entry must name the issue that will remove it. This list is the one +// concession in an otherwise zero-tolerance policy: an uncaught error is +// how this harness caught issue #150 in the first place. +const ALLOWED_ERRORS = [ + { + // libsodium ships a WASM build and an asm.js fallback. The + // extension CSP (script-src 'self', with no wasm-unsafe-eval) + // refuses the WASM module on every popup load; libsodium catches + // it and falls back to asm.js, so the wallet works. Deciding + // which backend actually ships is issue #182, and this entry gets + // deleted when that lands. + issue: "#182", + pattern: /Refused to compile or instantiate WebAssembly module/, + }, +]; + +function isAllowed(text) { + return ALLOWED_ERRORS.some((a) => a.pattern.test(text)); +} + +// Collects every uncaught page error, console.error and unstubbed +// request, and hands each one to exactly one reporter. +// +// This deliberately has NO window API. It used to expose mark()/since() +// so a test could ask for "the errors since I started", and that shape +// produced a green run that proved nothing twice over: first the mark +// started after test 1, so everything recorded during launch was +// discarded, then the tail after the final test was never read at all. In +// both cases a record fell outside somebody's window and vanished, which +// is the precise failure this harness exists to prevent. +// +// So there is no window left to fall outside of. take() is the only +// reader and it always takes everything outstanding, so successive takes +// partition the entire record stream with no gaps, and the runner turns +// every record it reads into a failure. +// +// Observation ends when the browser context is closed. Nothing records +// after that — the route handler and the console listeners are gone with +// the context — so there is no post-teardown phase to collect, and this +// class deliberately offers no mechanism pretending to cover one. +class ErrorCollector { + constructor() { + this.entries = []; + this.taken = 0; + } + + record(kind, text) { + const line = kind + ": " + String(text).split("\n")[0]; + if (isAllowed(line)) return; + this.entries.push(line); + } + + // Everything recorded since the previous take(). Never yields a + // record twice and never skips one. + take() { + const out = this.entries.slice(this.taken); + this.taken = this.entries.length; + return out; + } +} + +function attachErrorListeners(ctx, errors) { + const attachPage = (page) => { + page.on("pageerror", (err) => { + errors.record("pageerror", err.message || String(err)); + }); + page.on("console", (msg) => { + if (msg.type() === "error") { + errors.record("console.error", msg.text()); + } + }); + }; + ctx.pages().forEach(attachPage); + ctx.on("page", attachPage); + // Per-page listeners only: the context-level "weberror" event covers + // the same page exceptions and would double-report them. Playwright + // exposes no error EVENT for service workers, so an uncaught + // exception in the background worker is not visible here — everything + // this suite drives lives in the popup page. That is an error-channel + // gap only: worker NETWORK traffic is intercepted and reported like + // any other, and assertWorkerTrafficIntercepted() below fails the run + // if it ever stops being. +} + +async function serviceWorker(ctx) { + const [existing] = ctx.serviceWorkers(); + if (existing) return existing; + return ctx.waitForEvent("serviceworker", { timeout: 30000 }); +} + +// How long to wait for the background worker's first outbound request. +// +// The margin that actually decides whether this check is sound is not +// this timeout — it is whether the route handler is installed before the +// worker fetches. Measured over several runs: route installation +// completes 11-23ms after the context comes up, and the worker's +// blocklist fetch arrives 525-883ms after that, so the route wins by +// roughly 25-50x. This 30s figure is only slack for a loaded machine on +// top of that; losing the race fails the run rather than passing it +// quietly, which was verified by forcing a 3s delay before route +// installation. +const WORKER_TRAFFIC_TIMEOUT_MS = 30000; + +// ctx.route() only sees service-worker requests when Playwright runs with +// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e +// sets. Without it the worker's traffic — notably the phishing blocklist +// fetch src/background/index.js issues at startup — goes to the real +// internet, and nothing says so, because src/shared/phishingDomains.js +// swallows fetch failures. A harness whose isolation can lapse in silence +// is worthless, so this does not take the flag on trust: the background +// worker's own startup fetch has to show up in the route handler, or the +// suite refuses to run. +// +// Deliberately NOT a synthetic probe fetched through worker.evaluate(): +// evaluating in an extension worker this early kills it (the call fails +// with "Target page, context or browser has been closed" and the worker +// disappears), which would break the very thing being measured. Observing +// traffic the extension already generates costs nothing and cannot +// perturb it. +async function assertWorkerTrafficIntercepted(stubs) { + const seen = await stubs.waitForServiceWorkerTraffic( + WORKER_TRAFFIC_TIMEOUT_MS, + ); + if (seen) return seen; + + // State the observation, not a conclusion. This fires for at least + // two quite different causes and the harness cannot tell them apart + // from here, so guessing one of them in the message sends the reader + // the wrong way. + throw new Error( + "observed no service-worker request in the route handler within " + + WORKER_TRAFFIC_TIMEOUT_MS + + "ms. Under working interception the background worker's " + + "startup blocklist fetch (src/background/index.js) reaches the " + + "handler about half a second after the route is installed. " + + "Two causes are plausible and this check cannot distinguish " + + "them: (1) service-worker interception is not in effect, so " + + "that traffic went to the real internet unobserved — the suite " + + "must be run through script/test-e2e, which sets " + + "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " + + "Playwright upgrade may have dropped or renamed that flag; " + + "(2) no worker request was made in the first place — the route " + + "lost the startup race, or the worker no longer fetches at " + + "startup, in which case this check needs a new anchor because " + + "there is no longer any worker traffic to observe. Either way " + + "the fix is a replacement mechanism or an honest downgrade of " + + "the isolation claims in tests/e2e/network.js and README.md — " + + "not deleting this check", + ); +} + +async function launch(routeOpts) { + if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) { + throw new Error( + "no unpacked build at " + + EXT_PATH + + " — run make build before the e2e suite", + ); + } + + const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-")); + const ctx = await chromium.launchPersistentContext(userDir, { + // channel: "chromium" is load-bearing. The default headless mode + // uses the headless shell, which silently refuses to load + // extensions: there is no error at all, the service worker simply + // never appears. This cost real debugging time once already. + channel: "chromium", + headless: true, + args: [ + "--disable-extensions-except=" + EXT_PATH, + "--load-extension=" + EXT_PATH, + // The container runs unprivileged; Chrome's sandbox needs + // capabilities the harness deliberately does not grant it. + "--no-sandbox", + // Belt to the interception braces: nothing that slips past + // the route handler can resolve a name, so a request that + // escapes cannot actually reach the internet. Detection is + // still assertWorkerTrafficIntercepted()'s job — this only + // bounds the damage while a gap goes unnoticed. Playwright + // fulfils routed requests without touching the resolver, and + // it drives the browser over a pipe, so neither is affected. + "--host-resolver-rules=MAP * ~NOTFOUND", + ], + }); + + const cleanup = async () => { + await ctx.close().catch(() => {}); + fs.rmSync(userDir, { recursive: true, force: true }); + }; + + try { + const errors = new ErrorCollector(); + attachErrorListeners(ctx, errors); + routeOpts.report = (text) => errors.record("network", text); + const stubs = await installNetworkStubs(ctx, routeOpts); + + await assertWorkerTrafficIntercepted(stubs); + + // The extension id is derived from the unpacked path, so it + // changes and must never be hardcoded. It is the host part of the + // service worker URL. + const sw = await serviceWorker(ctx); + const id = new URL(sw.url()).host; + + return { + ctx, + errors, + extensionId: id, + popupUrl: "chrome-extension://" + id + "/src/popup/index.html", + close: cleanup, + }; + } catch (e) { + // Anything that fails after the browser is up has to tear it down + // on the way out: an orphaned context keeps node alive forever, + // turning a clean failure into a hung run. + await cleanup(); + throw e; + } +} + +// ---------------------------------------------------------------- flows + +const PASSWORD = "e2e-harness-password"; + +async function visible(page, selector, timeout = 15000) { + await page.waitForSelector(selector, { state: "visible", timeout }); +} + +async function openPopup(ctx, popupUrl) { + const page = await ctx.newPage(); + await page.goto(popupUrl); + return page; +} + +// Full wallet creation through the real UI: BIP-39 generation, libsodium +// vault encryption and extension storage persistence, for real. +async function createWallet(page) { + await page.click("#btn-welcome-add"); + await visible(page, "#view-add-wallet"); + await page.click("#btn-generate-phrase"); + await page.waitForFunction(() => { + const el = document.getElementById("wallet-mnemonic"); + return el && el.value.trim().split(/\s+/).length >= 12; + }); + 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); +} + +// Reach the address detail screen from wherever the popup restored to. +// Clicking .address-row does not open it; the [info] button does. +async function openAddressDetail(page) { + const onAddress = await page.isVisible("#view-address"); + if (!onAddress) { + await visible(page, "#view-main"); + await page.click("#wallet-list .btn-addr-info"); + } + await visible(page, "#view-address"); +} + +module.exports = { + createWallet, + launch, + openAddressDetail, + openPopup, + visible, +}; diff --git a/tests/e2e/network.js b/tests/e2e/network.js new file mode 100644 index 0000000..034e940 --- /dev/null +++ b/tests/e2e/network.js @@ -0,0 +1,334 @@ +// Browser-level network interception for the end-to-end suite. +// +// Every http(s) request the extension makes — from the popup page AND +// from the MV3 background service worker — is fulfilled from these +// fixtures, so the suite is deterministic and runs entirely offline. The +// probe that motivated this harness (see issue #181) observed live calls +// to Blockscout returning 401 inside the container, which would make any +// assertion about rendered transaction data worthless. +// +// Service-worker coverage is not free: ctx.route() only sees worker +// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in +// the environment, which script/test-e2e does. Without it the phishing +// blocklist fetch that src/background/index.js issues at worker startup +// silently reaches raw.githubusercontent.com on the open internet, and +// src/shared/phishingDomains.js swallows the failure so nothing surfaces +// it. That is not left to trust: waitForServiceWorkerTraffic() below +// backs the launch-time canary in harness.js, which fails the entire +// suite if worker requests stop being visible here. +// +// Anything not explicitly stubbed here is aborted AND reported to the +// error collector, so a newly added outbound call shows up as a test +// failure rather than as intermittent flakiness. + +"use strict"; + +// Fictional ERC-20 used to seed the transaction-detail test. The symbol +// must not collide with any entry in src/shared/tokenList.js, or +// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a +// symbol-spoofing attempt; holders_count must be >= 1000 or the default +// hideLowHolderTokens filter drops it. Either would make the test pass +// vacuously by never rendering a row at all. +const STUB_TOKEN = { + address: "0xe2e0000000000000000000000000000000000e2e", + symbol: "E2E", + name: "End To End Test Token", + decimals: "6", + holders: "12345", +}; + +const STUB_COUNTERPARTY = "0xc0ffee0000000000000000000000000000c0ffee"; + +const STUB_TX_HASH = + "0xe2e0000000000000000000000000000000000000000000000000000000000e2e"; + +const STUB_BLOCK_NUMBER = 21000000; + +// Fixed instant so timeAgo() output is stable across runs. +const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; + +// A 32-byte zero word. Returned for every eth_call, which is what makes +// ethers' ENS reverse lookup resolve to "no resolver set" and return null +// instead of throwing. A throw would be logged by src/shared/ens.js via +// log.errorf(), i.e. console.error, which fails the run on its own. +const ZERO_WORD = "0x" + "0".repeat(64); + +const RPC_RESULTS = { + eth_chainId: "0x1", + net_version: "1", + eth_blockNumber: "0x1406f40", + eth_getBalance: "0x0", + eth_call: ZERO_WORD, + eth_gasPrice: "0x3b9aca00", + eth_estimateGas: "0x5208", + eth_getTransactionCount: "0x0", + eth_maxPriorityFeePerGas: "0x3b9aca00", +}; + +function tokenObject() { + return { + address_hash: STUB_TOKEN.address, + address: STUB_TOKEN.address, + symbol: STUB_TOKEN.symbol, + name: STUB_TOKEN.name, + decimals: STUB_TOKEN.decimals, + holders_count: STUB_TOKEN.holders, + type: "ERC-20", + }; +} + +// One received ERC-20 transfer of 1.5 E2E to the address under test. +function tokenTransferItems(address) { + return [ + { + transaction_hash: STUB_TX_HASH, + block_number: STUB_BLOCK_NUMBER, + timestamp: STUB_TX_TIMESTAMP, + from: { hash: STUB_COUNTERPARTY }, + to: { hash: address }, + total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, + token: tokenObject(), + }, + ]; +} + +// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata +// decoder short-circuits; the on-chain detail fields still populate. +function transactionDetails() { + return { + hash: STUB_TX_HASH, + block_number: STUB_BLOCK_NUMBER, + nonce: 7, + gas_used: "51000", + gas_price: "1000000000", + fee: { value: "51000000000000" }, + raw_input: "0x", + status: "ok", + }; +} + +function jsonResponse(route, body) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +// Extract the address from a Blockscout /addresses//... path. +function blockscoutAddress(pathname) { + const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//); + return m ? m[1] : null; +} + +function handleRpc(route, postData, report) { + let payload; + try { + payload = JSON.parse(postData || "null"); + } catch { + report("unstubbed RPC: unparseable body " + String(postData)); + return route.abort(); + } + // ethers batches by default, so the body may be an array. + const batch = Array.isArray(payload) ? payload : [payload]; + + // Anything that is not a JSON-RPC object, or a batch of them, is not + // RPC at all and must be reported like any other unrecognised + // outbound traffic rather than dereferenced. request.postData() + // returns null both for a bodyless POST and for a body Playwright + // cannot decode as UTF-8 (sendBeacon with a Blob, or any binary + // payload), so this is not an empty-string special case: it rejects + // every non-object payload, exactly as the catch above rejects every + // unparseable one. + if ( + payload === null || + typeof payload !== "object" || + !batch.every((req) => req !== null && typeof req === "object") + ) { + report("unstubbed request: POST " + route.request().url()); + return route.abort(); + } + const replies = batch.map((req) => { + const result = RPC_RESULTS[req.method]; + if (result === undefined) { + report("unstubbed RPC method: " + req.method); + return { + jsonrpc: "2.0", + id: req.id, + error: { code: -32601, message: "unstubbed in e2e harness" }, + }; + } + return { jsonrpc: "2.0", id: req.id, result }; + }); + return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); +} + +const TRACE_TRUE = ["1", "true", "yes", "on"]; +const TRACE_FALSE = ["", "0", "false", "no", "off"]; + +// Whether E2E_TRACE_NETWORK asks for the request trace. +// +// A set-but-unrecognised value is a hard error rather than a quiet +// "off": E2E_TRACE_NETWORK=true asking for a trace and getting silence +// is the operator being lied to about what the harness is doing, which +// is the whole failure mode this suite exists to eliminate. Refusing to +// guess costs one line and one obvious error message. +function traceEnabled(raw) { + if (raw === undefined || raw === null) return false; + const v = String(raw).trim().toLowerCase(); + if (TRACE_TRUE.includes(v)) return true; + if (TRACE_FALSE.includes(v)) return false; + throw new Error( + "E2E_TRACE_NETWORK is set to " + + JSON.stringify(String(raw)) + + ", which is not a recognised on/off value. Use one of " + + TRACE_TRUE.join(", ") + + " to enable the request trace, or one of " + + TRACE_FALSE.slice(1).join(", ") + + " to disable it. Refusing to guess: a diagnostic that silently " + + "does nothing is worse than one that is not there", + ); +} + +/** + * Route every http(s) request through local fixtures. + * + * @param {import("playwright-core").BrowserContext} ctx + * @param {object} opts + * @param {(text: string) => void} opts.report called for unstubbed traffic + * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 + * transfer. Read at request time, so a test can flip it on the same + * options object without re-registering the route. + * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => + * Promise}>} + */ +async function installNetworkStubs(ctx, opts) { + const report = opts.report; + + // First request seen that originated in a service worker, and the + // resolver waiting for it. This is what proves worker interception is + // actually in force; see waitForServiceWorkerTraffic below. + let firstWorkerRequest = null; + let announceWorkerRequest = null; + + // E2E_TRACE_NETWORK=1 prints every request that reaches this handler, + // tagged [sw] when it originated in the background service worker. + // It exists so the isolation claim above can be re-checked by anyone + // in one command, without editing files: the phishing blocklist fetch + // showing up with an [sw] tag is the proof that the worker really is + // intercepted and that the raw.githubusercontent.com stub below is + // live code rather than decoration. + const trace = traceEnabled(process.env.E2E_TRACE_NETWORK); + + // Regex rather than a glob so chrome-extension:// resource loads are + // never touched — routing those would break the popup itself. + await ctx.route(/^https?:\/\//, async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const p = url.pathname; + const fromWorker = !!req.serviceWorker(); + + if (fromWorker && !firstWorkerRequest) { + firstWorkerRequest = req.method() + " " + req.url(); + if (announceWorkerRequest) + announceWorkerRequest(firstWorkerRequest); + } + + if (trace) { + const origin = fromWorker ? "[sw] " : "[page] "; + console.log("# routed " + origin + req.method() + " " + req.url()); + } + + // JSON-RPC endpoint (any host): a POST with a JSON-RPC body. + if (req.method() === "POST") { + return handleRpc(route, req.postData(), report); + } + + // Blockscout v2 + if (p.includes("/api/v2/")) { + if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { + return jsonResponse(route, { items: [] }); + } + if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { + const addr = blockscoutAddress(p); + return jsonResponse(route, { + items: + opts.seedTokenTransfer && addr + ? tokenTransferItems(addr) + : [], + }); + } + if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { + return jsonResponse(route, []); + } + if (p.endsWith("/transactions/" + STUB_TX_HASH)) { + return jsonResponse(route, transactionDetails()); + } + } + + // CoinDesk price tick + if (url.hostname.endsWith("coindesk.com")) { + return jsonResponse(route, { Data: {} }); + } + + // MetaMask phishing blocklist + if ( + url.hostname === "raw.githubusercontent.com" || + p.endsWith("/eth-phishing-detect/main/src/config.json") + ) { + return jsonResponse(route, { + version: 2, + tolerance: 2, + fuzzylist: [], + whitelist: [], + blacklist: [], + }); + } + + // Best-effort Etherscan address labels: served as an empty page. + if (url.hostname.endsWith("etherscan.io")) { + return route.fulfill({ + status: 200, + contentType: "text/html", + body: "", + }); + } + + report("unstubbed request: " + req.method() + " " + req.url()); + return route.abort(); + }); + + return { + /** + * Resolve with the first service-worker-originated request this + * handler saw, or null if none arrives within `ms`. + * + * The background worker fetches the phishing blocklist at + * startup, unconditionally, within about a second of the context + * coming up — so under working interception this resolves almost + * immediately. Nothing arriving means worker traffic is bypassing + * the handler entirely and going to the real internet, which the + * caller turns into a hard failure of the whole suite. + */ + waitForServiceWorkerTraffic(ms) { + if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest); + return new Promise((resolve) => { + const timer = setTimeout(() => { + announceWorkerRequest = null; + resolve(null); + }, ms); + announceWorkerRequest = (req) => { + clearTimeout(timer); + announceWorkerRequest = null; + resolve(req); + }; + }); + }, + }; +} + +module.exports = { + installNetworkStubs, + STUB_TOKEN, + STUB_TX_HASH, +}; diff --git a/tests/e2e/run.js b/tests/e2e/run.js new file mode 100644 index 0000000..a771e3c --- /dev/null +++ b/tests/e2e/run.js @@ -0,0 +1,264 @@ +// End-to-end suite entrypoint. Run via script/test-e2e (which builds +// dist/chrome/ and starts the pinned container); running it directly +// requires a Chromium that playwright-core can find. +// +// A plain runner rather than jest on purpose: jest's default testMatch +// would pull these files into script/test, and browser tests do not fit +// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing +// here is named *.test.js for the same reason. + +"use strict"; + +const { + createWallet, + launch, + openAddressDetail, + openPopup, + visible, +} = require("./harness"); +const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); + +const TEST_TIMEOUT_MS = 120000; + +// How long to keep collecting after the final test returns; see the +// trailing drain in main(). +const TRAILING_WATCH_MS = 1500; + +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, message) { + if (!cond) throw new Error(message); +} + +function withTimeout(promise, name) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => + reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")), + TEST_TIMEOUT_MS, + ); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +// ----------------------------------------------------------------- tests + +test("popup loads and reaches the welcome view", async (env) => { + env.page = await openPopup(env.ctx, env.popupUrl); + await visible(env.page, "#view-welcome"); + const title = await env.page.title(); + assert(title === "AutistMask", "unexpected popup title: " + title); +}); + +test("wallet creation through the UI reaches the main view", async (env) => { + await createWallet(env.page); + const addrCount = await env.page + .locator("#wallet-list .btn-addr-info") + .count(); + assert(addrCount > 0, "no addresses rendered in the wallet list"); +}); + +test("add token screen opens from address detail (#150)", async (env) => { + await openAddressDetail(env.page); + await env.page.click("#btn-add-token"); + await visible(env.page, "#view-add-token"); + const quickPicks = await env.page + .locator("#common-token-list .common-token") + .count(); + assert(quickPicks > 0, "no common-token quick-pick buttons rendered"); +}); + +test("transaction detail renders an ERC-20 transfer (#151)", async (env) => { + // Serve the stubbed token transfer from here on, then reload so the + // address detail screen refetches its transaction list. + env.routeOpts.seedTokenTransfer = true; + await env.page.reload(); + await openAddressDetail(env.page); + + await visible(env.page, "#tx-list .tx-row"); + const rowText = await env.page + .locator("#tx-list .tx-row") + .first() + .innerText(); + assert( + rowText.includes(STUB_TOKEN.symbol), + "token transfer row missing symbol " + + STUB_TOKEN.symbol + + ", got: " + + JSON.stringify(rowText), + ); + + await env.page.locator("#tx-list .tx-row").first().click(); + await visible(env.page, "#view-transaction"); + + const hash = await env.page.locator("#tx-detail-hash").innerText(); + assert( + hash.includes(STUB_TX_HASH), + "transaction detail shows the wrong hash: " + hash, + ); + + // The token contract row is the field that crashes when + // addressDotHtml is not imported: it renders only for transfers with + // a contractAddress, which is every ERC-20 transfer. + await visible(env.page, "#tx-detail-token-contract-section"); + const contract = env.page.locator("#tx-detail-token-contract"); + const contractText = await contract.innerText(); + assert( + contractText.toLowerCase().includes(STUB_TOKEN.address), + "token contract row missing the contract address, got: " + + JSON.stringify(contractText), + ); + const dots = await contract.locator('span[style*="border-radius"]').count(); + assert(dots > 0, "token contract row rendered without its colour dot"); +}); + +// ---------------------------------------------------------------- runner + +async function main() { + // A suite that runs nothing must never report success. If a refactor + // drops the registrations above, or a require() of this file stops + // reaching them, the only honest outcome is a red run — reporting + // "0/0 passed" and exiting 0 is the same vacuous-check failure this + // whole harness exists to prevent. + if (tests.length === 0) { + console.log("1..0"); + console.log("# FAILED: the e2e suite registered no tests"); + process.exitCode = 1; + return; + } + + const routeOpts = { seedTokenTransfer: false }; + + let session; + try { + session = await launch(routeOpts); + } catch (e) { + // Never skip and report success: a browser we cannot start, or + // one whose network interception is not in force, is a failure of + // the suite, not an absent one. + console.error("e2e: cannot run the suite: " + e.message); + process.exitCode = 1; + return; + } + + console.log("# extension id: " + session.extensionId); + console.log("1.." + tests.length); + + const env = { + ctx: session.ctx, + popupUrl: session.popupUrl, + routeOpts, + page: null, + }; + + // Attribution of collected errors is total. session.errors has no + // window API at all: take() always drains everything outstanding, so + // successive takes partition the whole stream, and the phases below + // cover the entire life of the run. Nothing the collector holds can + // go unread. + // + // launch .. end of test 1 -> test 1 (so the worker's startup + // fetches land on a test, not + // nowhere) + // end of test k .. end of k+1 -> test k+1 + // last test .. teardown -> the suite, via the trailing drain + // + // Those three phases cover the entire life of the browser context. + // There is no fourth: once the context is closed nothing can record, + // because the route handler and the console listeners died with it. + // Traffic that a test defers past the trailing drain is therefore + // never observed at all — a real limit of this design, stated in the + // README, and not one any post-teardown hook could close. + // + // Two green-but-vacuous runs on this harness were the same shape: a + // record falling outside somebody's window and being dropped. First + // the mark started after test 1, discarding launch-time records; + // then the tail after the last test was never read. Patching a + // second boundary would have invited a third, so the window concept + // is gone rather than fixed. + let failed = 0; + let n = 0; + for (const t of tests) { + n += 1; + let failure = null; + try { + await withTimeout(t.fn(env), t.name); + } catch (e) { + failure = e.message; + } + + // Any uncaught page error, console.error or unstubbed request + // fails the test that provoked it, whether or not its assertions + // passed. This is the mechanism that caught #150. + const newErrors = session.errors.take(); + if (!failure && newErrors.length > 0) { + failure = "uncaught browser errors during this test"; + } + + if (failure) { + failed += 1; + console.log("not ok " + n + " - " + t.name); + console.log(" " + failure); + for (const line of newErrors) { + console.log(" " + line); + } + } else { + console.log("ok " + n + " - " + t.name); + } + } + + // Keep watching after the last test returns, before tearing the + // browser down. A request a test fires without awaiting is still in + // flight when its function resolves; measured here it reaches the + // route handler about 10ms later, but closing the context does not + // wait for it — with no window at all the request dies unobserved + // and the run goes green, which is exactly how escaping traffic + // stays invisible. + // + // A fixed bounded window rather than a quiescence poll on purpose: + // the collector being quiet is not evidence, because a request that + // has not been dispatched yet has recorded nothing to be quiet + // about. Playwright offers no "is anything in flight" question to + // ask either — the route handler is the only observation point — so + // a grace period is the mechanism available, and this one is ~150x + // the measured latency for 1.5s on a ~25s suite. + await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS)); + + await session.close(); + + // The tail. These cannot be blamed on any single test, so they are + // reported against the suite rather than guessed at — but they are + // reported, and they fail the run. + const trailing = session.errors.take(); + + console.log( + "# " + (tests.length - failed) + "/" + tests.length + " tests passed", + ); + + if (trailing.length > 0) { + console.log( + "# " + + trailing.length + + " browser error(s) recorded after the last test finished, " + + "not attributable to any single test:", + ); + for (const line of trailing) { + console.log("# " + line); + } + } + + if (failed > 0 || trailing.length > 0) { + console.log("# FAILED"); + process.exitCode = 1; + } +} + +main().catch((e) => { + console.error("e2e: " + (e && e.stack ? e.stack : e)); + process.exitCode = 1; +}); diff --git a/yarn.lock b/yarn.lock index ca84232..3602e67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2547,6 +2547,11 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.56.0: + version "1.56.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.0.tgz#14b40ea436551b0bcefe19c5bfb8d1804c83739c" + integrity sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ== + pngjs@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" -- 2.49.1 From 188882d6354acb201764e896d7e55f233c5f2d7a Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 10 Aug 2026 15:53:15 +0200 Subject: [PATCH 02/44] test: cover the address-poisoning filters in transactions.js (closes #160) 47 tests over src/shared/transactions.js: both real address-poisoning attacks as fixtures, each of the four filters on and off, threshold boundaries, no false positives, and the per-address merge/dedup path. No source file changed. --- TODO.md | 2 + tests/transactions.test.js | 1002 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1004 insertions(+) create mode 100644 tests/transactions.test.js diff --git a/TODO.md b/TODO.md index 0f0f652..3f1366f 100644 --- a/TODO.md +++ b/TODO.md @@ -37,6 +37,8 @@ review. launch-time canary aborts the suite if worker traffic starts escaping. - 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog (#149-#168). +- 2026-08-09: Test coverage for the address-poisoning defense in + `src/shared/transactions.js` (#160) - 2026-07-26: About well in settings with build info, repo link and the version click easter egg (#145); proper view navigation stack (#146). - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile diff --git a/tests/transactions.test.js b/tests/transactions.test.js new file mode 100644 index 0000000..73bf7b7 --- /dev/null +++ b/tests/transactions.test.js @@ -0,0 +1,1002 @@ +// Tests for the address-poisoning defense in src/shared/transactions.js. +// +// README.md:730-814 describes four filters as a core security property of +// the wallet: known token symbol verification, the low-holder threshold, the +// fraud contract blocklist, and the dust threshold. A regression in any of +// them does not crash — it silently stops filtering, and poisoned look-alike +// addresses reappear in the user's transaction history. These tests pin the +// behaviour down in both directions: the documented attacks must be filtered, +// and legitimate transactions must survive untouched. +// +// Fixtures are static local objects modelled on the two real attacks cited in +// the README. Nothing here touches the network: global.fetch is replaced +// with a throwing stub and the only fetch path in the module under test +// (debugFetch, from src/shared/log) is mocked at the module boundary. + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: jest.fn(), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +global.fetch = jest.fn(() => { + throw new Error("tests must not perform network requests"); +}); + +// state.js reads chrome.storage.local at module load; stub it so the +// default settings can be asserted against what the README promises. +global.chrome = { storage: { local: {} } }; + +const { + fetchRecentTransactions, + filterTransactions, +} = require("../src/shared/transactions"); +const { KNOWN_SYMBOLS } = require("../src/shared/tokenList"); +const { debugFetch } = require("../src/shared/log"); +const { state } = require("../src/shared/state"); + +// --------------------------------------------------------------------------- +// Addresses and hashes from the two attacks documented in README.md:730-814. +// --------------------------------------------------------------------------- + +// The fake "Ethereum" token with symbol "ETH" and zero holders +// (README.md:735-750). +const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82"; +// The fraudulent Transfer event it emitted. +const FAKE_ETH_TRANSFER_HASH = + "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84"; +// Our test address, the claimed sender of the fake transfer. +const VICTIM = "0x66133e8ea0f5d1d612d2502a968757d1048c214a"; +// The legitimate recipient of the real 0.005 ETH send. +const LEGIT_RECIPIENT = "0xc3c693ae04bad5f13c45885c1e85a9557798f37e"; +// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6"). +const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f"; + +// The second wave: a real 1 gwei native transfer (README.md:800-808). +const DUST_TX_HASH = + "0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74"; +const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e"; + +// Genuine contracts from the shipped token list. +const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; + +// A poisoning contract that uses a symbol not present in the token list, so +// only the holder-count and blocklist rules can catch it. +const NOVEL_SPAM_CONTRACT = "0x1111111111111111111111111111111111111111"; +const NOVEL_SPAM_SYMBOL = "SPAMTKN"; + +// An ordinary counterparty for legitimate-transaction fixtures. +const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11"; + +// The documented default settings (README.md:810-814, state.js:24-27). +const DEFAULT_FILTERS = { + hideLowHolderTokens: true, + hideFraudContracts: true, + hideDustTransactions: true, + dustThresholdGwei: 100000, + fraudContracts: [], +}; + +function filters(overrides) { + return { ...DEFAULT_FILTERS, ...overrides }; +} + +// --------------------------------------------------------------------------- +// Fixture builders producing objects shaped exactly like the parsed entries +// that fetchRecentTransactions hands to filterTransactions. +// --------------------------------------------------------------------------- + +// A native (non-token) transaction. valueGwei is set, contractAddress and +// holders are null, as parseTx produces. +function nativeTx(overrides = {}) { + return { + hash: "0x" + "a".repeat(64), + blockNumber: 21000000, + timestamp: 1740657600, + from: ORDINARY_PEER, + to: VICTIM, + value: "0.0500", + exactValue: "0.05", + rawAmount: "50000000000000000", + rawUnit: "wei", + valueGwei: 50000000, + symbol: "ETH", + direction: "received", + directionLabel: "Received", + isError: false, + contractAddress: null, + holders: null, + isContractCall: false, + method: null, + ...overrides, + }; +} + +// An ERC-20 transfer entry. contractAddress is lowercased and holders is a +// number, as parseTokenTransfer produces. +function tokenTx(overrides = {}) { + return { + hash: "0x" + "b".repeat(64), + blockNumber: 21000000, + timestamp: 1740657600, + from: ORDINARY_PEER, + to: VICTIM, + value: "1500.5000", + exactValue: "1500.5", + rawAmount: "1500500000", + rawUnit: "USDC base units (10^-6)", + valueGwei: null, + symbol: "USDC", + direction: "received", + directionLabel: "Received", + isError: false, + contractAddress: USDC_CONTRACT, + holders: 3500000, + ...overrides, + }; +} + +// Attack 1: the fake "Ethereum"/"ETH" token transfer claiming the victim sent +// 0.005 "ETH" to the look-alike scam address. +function fakeEthTokenTransfer() { + return tokenTx({ + hash: FAKE_ETH_TRANSFER_HASH, + from: VICTIM, + to: TOKEN_SCAM_LOOKALIKE, + value: "0.0050", + exactValue: "0.005", + rawAmount: "5000000000000000", + rawUnit: "ETH base units (10^-18)", + symbol: "ETH", + direction: "sent", + directionLabel: "Sent", + contractAddress: FAKE_ETH_CONTRACT, + holders: 0, + }); +} + +// Attack 2: the real 1 gwei native transfer from the look-alike address. +function nativeDustTransfer() { + return nativeTx({ + hash: DUST_TX_HASH, + from: DUST_SENDER_LOOKALIKE, + to: VICTIM, + value: "0.0000", + exactValue: "0.000000001", + rawAmount: "1000000000", + valueGwei: 1, + direction: "received", + directionLabel: "Received", + }); +} + +// The legitimate 0.005 ETH send that the attacks piggybacked on. +function legitimateEthSend() { + return nativeTx({ + hash: "0x" + "c".repeat(64), + from: VICTIM, + to: LEGIT_RECIPIENT, + value: "0.0050", + exactValue: "0.005", + rawAmount: "5000000000000000", + valueGwei: 5000000, + direction: "sent", + directionLabel: "Sent", + }); +} + +function hashesOf(result) { + return result.transactions.map((tx) => tx.hash); +} + +// --------------------------------------------------------------------------- + +describe("token list assumptions the fixtures rely on", () => { + test('"ETH" is a known symbol with no legitimate ERC-20 contract', () => { + expect(KNOWN_SYMBOLS.has("ETH")).toBe(true); + expect(KNOWN_SYMBOLS.get("ETH")).toBeNull(); + }); + + test("USDC and WETH map to their genuine lowercased contracts", () => { + expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT); + expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT); + }); + + test("the spam fixture symbol is not in the known token list", () => { + expect(KNOWN_SYMBOLS.has(NOVEL_SPAM_SYMBOL)).toBe(false); + }); +}); + +describe("the real attacks documented in README.md:730-814", () => { + test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => { + const attack = fakeEthTokenTransfer(); + const result = filterTransactions([attack], filters()); + expect(result.transactions).toEqual([]); + }); + + test("the fake token contract is reported as a newly found fraud contract", () => { + const result = filterTransactions([fakeEthTokenTransfer()], filters()); + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); + }); + + test("the 1 gwei native dust transfer is filtered by default", () => { + const result = filterTransactions([nativeDustTransfer()], filters()); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("both attacks are removed while the genuine send survives", () => { + const legit = legitimateEthSend(); + const result = filterTransactions( + [fakeEthTokenTransfer(), legit, nativeDustTransfer()], + filters(), + ); + expect(hashesOf(result)).toEqual([legit.hash]); + }); +}); + +describe("known-symbol spoof verification", () => { + test("a known symbol from a non-matching contract is spoofed", () => { + const spoof = tokenTx({ + symbol: "USDC", + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 5000000, + }); + const result = filterTransactions([spoof], filters()); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]); + }); + + test("the genuine contract for that symbol is not spoofed", () => { + const genuine = tokenTx(); + const result = filterTransactions([genuine], filters()); + expect(result.transactions).toEqual([genuine]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("an unknown symbol from an unknown contract is not flagged by this check", () => { + const unknown = tokenTx({ + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 25000, + }); + const result = filterTransactions([unknown], filters()); + expect(result.transactions).toEqual([unknown]); + expect(result.newFraudContracts).toEqual([]); + }); + + test('"ETH" as an ERC-20 is spoofed no matter which contract emits it', () => { + // KNOWN_SYMBOLS maps "ETH" to null: there is no legitimate ERC-20 + // "ETH", so even the real WETH contract claiming it is a spoof. + const fromWeth = tokenTx({ + symbol: "ETH", + contractAddress: WETH_CONTRACT, + holders: 900000, + }); + const result = filterTransactions([fromWeth], filters()); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([WETH_CONTRACT]); + }); + + test("symbol comparison is case-insensitive", () => { + const spoof = tokenTx({ + symbol: "usdc", + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 5000000, + }); + expect(filterTransactions([spoof], filters()).transactions).toEqual([]); + }); + + test("a native transaction has no contract and is never spoof-filtered", () => { + const native = nativeTx({ symbol: "ETH" }); + const result = filterTransactions([native], filters()); + expect(result.transactions).toEqual([native]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("a repeated fraud contract is reported only once", () => { + const result = filterTransactions( + [fakeEthTokenTransfer(), fakeEthTokenTransfer()], + filters(), + ); + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); + }); + + test("an already-known fraud contract is not reported as new", () => { + const result = filterTransactions( + [fakeEthTokenTransfer()], + filters({ fraudContracts: [FAKE_ETH_CONTRACT] }), + ); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("an already-known fraud contract given in checksummed form is not reported as new", () => { + const result = filterTransactions( + [fakeEthTokenTransfer()], + filters({ + fraudContracts: ["0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82"], + }), + ); + expect(result.newFraudContracts).toEqual([]); + }); + + // Documents current behaviour, not desired behaviour: the spoof check + // compares tx.contractAddress against a lowercased known address with + // ===, so a caller passing a checksummed address for a genuine token has + // it treated as a spoof. In the app this cannot happen because + // parseTokenTransfer lowercases, but the exported function is not + // defensive about it the way the blocklist check is. + test("current behaviour: a checksummed genuine contract is treated as a spoof", () => { + const genuineButChecksummed = tokenTx({ + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }); + const result = filterTransactions([genuineButChecksummed], filters()); + expect(result.transactions).toEqual([]); + }); + + // Documents current behaviour: README.md:810-814 says all four filters + // "default to on but can be individually disabled". There is no setting + // for known-symbol verification, and filterTransactions applies it + // unconditionally, so it cannot be turned off. + test("current behaviour: spoof filtering cannot be disabled by any setting", () => { + const allFiltersOff = { + hideLowHolderTokens: false, + hideFraudContracts: false, + hideDustTransactions: false, + dustThresholdGwei: 1, + fraudContracts: [], + }; + const result = filterTransactions( + [fakeEthTokenTransfer()], + allFiltersOff, + ); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); + }); + + test("current behaviour: spoof filtering also applies with no filters argument", () => { + const result = filterTransactions([fakeEthTokenTransfer()]); + expect(result.transactions).toEqual([]); + }); +}); + +describe("low-holder token filtering (the 1,000-holder rule)", () => { + function spamWithHolders(holders) { + return tokenTx({ + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: NOVEL_SPAM_CONTRACT, + holders: holders, + }); + } + + test("a zero-holder poisoning token is filtered", () => { + const result = filterTransactions([spamWithHolders(0)], filters()); + expect(result.transactions).toEqual([]); + }); + + test("boundary: 999 holders is filtered", () => { + const result = filterTransactions([spamWithHolders(999)], filters()); + expect(result.transactions).toEqual([]); + }); + + test("boundary: exactly 1000 holders is kept", () => { + const tx = spamWithHolders(1000); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + test("boundary: 1001 holders is kept", () => { + const tx = spamWithHolders(1001); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + test("the rule is bypassed when hideLowHolderTokens is off", () => { + const tx = spamWithHolders(0); + const result = filterTransactions( + [tx], + filters({ hideLowHolderTokens: false }), + ); + expect(result.transactions).toEqual([tx]); + }); + + test("native transactions have no holder count and are unaffected", () => { + const tx = legitimateEthSend(); + expect(tx.holders).toBeNull(); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); +}); + +describe("fraud contract blocklist", () => { + function blocklistedTx() { + return tokenTx({ + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 50000, + }); + } + + test("a transfer from a blocklisted contract is filtered", () => { + const result = filterTransactions( + [blocklistedTx()], + filters({ fraudContracts: [NOVEL_SPAM_CONTRACT] }), + ); + expect(result.transactions).toEqual([]); + }); + + test("the blocklist is matched case-insensitively", () => { + const result = filterTransactions( + [blocklistedTx()], + filters({ + fraudContracts: [NOVEL_SPAM_CONTRACT.toUpperCase()], + }), + ); + expect(result.transactions).toEqual([]); + }); + + test("the blocklist is bypassed when hideFraudContracts is off", () => { + const tx = blocklistedTx(); + const result = filterTransactions( + [tx], + filters({ + hideFraudContracts: false, + fraudContracts: [NOVEL_SPAM_CONTRACT], + }), + ); + expect(result.transactions).toEqual([tx]); + }); + + test("a contract not on the blocklist is unaffected", () => { + const tx = blocklistedTx(); + const result = filterTransactions( + [tx], + filters({ fraudContracts: [FAKE_ETH_CONTRACT] }), + ); + expect(result.transactions).toEqual([tx]); + }); + + test("native transactions are never blocklist-filtered", () => { + const tx = legitimateEthSend(); + const result = filterTransactions( + [tx], + filters({ fraudContracts: [FAKE_ETH_CONTRACT] }), + ); + expect(result.transactions).toEqual([tx]); + }); + + test("a contract caught spoofing earlier in the batch blocks its later transfers", () => { + // The fake "Ethereum" contract is detected as a spoof, added to the + // working blocklist, and its later transfer under a novel symbol is + // then filtered by the blocklist rule rather than the spoof rule. + const later = tokenTx({ + hash: "0x" + "d".repeat(64), + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: FAKE_ETH_CONTRACT, + holders: 50000, + }); + const result = filterTransactions( + [fakeEthTokenTransfer(), later], + filters(), + ); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); + }); + + test("that later transfer survives when hideFraudContracts is off", () => { + const later = tokenTx({ + hash: "0x" + "d".repeat(64), + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: FAKE_ETH_CONTRACT, + holders: 50000, + }); + const result = filterTransactions( + [fakeEthTokenTransfer(), later], + filters({ hideFraudContracts: false }), + ); + expect(hashesOf(result)).toEqual([later.hash]); + }); +}); + +describe("dust threshold filtering", () => { + function dustOf(gwei) { + return nativeTx({ valueGwei: gwei }); + } + + test("boundary: 99,999 gwei is filtered at the 100,000 gwei default", () => { + const result = filterTransactions([dustOf(99999)], filters()); + expect(result.transactions).toEqual([]); + }); + + test("boundary: exactly 100,000 gwei is kept", () => { + const tx = dustOf(100000); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + test("boundary: 100,001 gwei is kept", () => { + const tx = dustOf(100001); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + test("the 100,000 gwei default applies when no threshold is supplied", () => { + const below = dustOf(99999); + const at = dustOf(100000); + const opts = { + hideLowHolderTokens: true, + hideFraudContracts: true, + hideDustTransactions: true, + fraudContracts: [], + }; + expect(filterTransactions([below], opts).transactions).toEqual([]); + expect(filterTransactions([at], opts).transactions).toEqual([at]); + }); + + test("a user-raised threshold is honoured on both sides", () => { + const opts = filters({ dustThresholdGwei: 5000000 }); + const below = dustOf(4999999); + const at = dustOf(5000000); + expect(filterTransactions([below], opts).transactions).toEqual([]); + expect(filterTransactions([at], opts).transactions).toEqual([at]); + }); + + test("dust filtering is bypassed when hideDustTransactions is off", () => { + const tx = nativeDustTransfer(); + const result = filterTransactions( + [tx], + filters({ hideDustTransactions: false }), + ); + expect(result.transactions).toEqual([tx]); + }); + + test("zero-value contract calls are never treated as dust", () => { + const approve = nativeTx({ + hash: "0x" + "e".repeat(64), + valueGwei: 0, + value: "", + exactValue: "", + direction: "contract", + directionLabel: "Approve", + isContractCall: true, + method: "approve", + }); + expect(filterTransactions([approve], filters()).transactions).toEqual([ + approve, + ]); + }); + + test("token transfers carry no gwei value and are never dust-filtered", () => { + const tx = tokenTx({ valueGwei: null }); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + // Documents current behaviour: the threshold is read as + // `filters.dustThresholdGwei || 100000`, so a user who sets the threshold + // to 0 (the natural way to ask for no dust filtering while leaving the + // toggle on) silently gets the 100,000 gwei default instead. + test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => { + const result = filterTransactions( + [dustOf(50)], + filters({ dustThresholdGwei: 0 }), + ); + expect(result.transactions).toEqual([]); + }); +}); + +describe("filter defaults promised by the README and Settings", () => { + test("all three toggles default to on and the threshold to 100,000 gwei", () => { + expect(state.hideLowHolderTokens).toBe(true); + expect(state.hideFraudContracts).toBe(true); + expect(state.hideDustTransactions).toBe(true); + expect(state.dustThresholdGwei).toBe(100000); + }); + + test("the fraud contract list starts empty", () => { + expect(state.fraudContracts).toEqual([]); + }); + + // Documents current behaviour: filterTransactions itself defaults every + // optional filter to off. The "default to on" promise is satisfied by + // the state defaults above, which every caller passes in; the pure + // function makes no assumption of its own. + test("current behaviour: with no filters argument only spoof filtering runs", () => { + const dust = nativeDustTransfer(); + const lowHolder = tokenTx({ + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 0, + }); + const result = filterTransactions([dust, lowHolder]); + expect(hashesOf(result)).toEqual([dust.hash, lowHolder.hash]); + }); +}); + +describe("legitimate transactions are never filtered", () => { + test("a plain ETH transfer of an ordinary amount survives all four rules", () => { + const tx = nativeTx(); + const result = filterTransactions([tx], filters()); + expect(result.transactions).toEqual([tx]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("a genuine high-holder USDC transfer survives all four rules", () => { + const tx = tokenTx(); + const result = filterTransactions([tx], filters()); + expect(result.transactions).toEqual([tx]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("a genuine WETH transfer survives all four rules", () => { + const tx = tokenTx({ + hash: "0x" + "f".repeat(64), + symbol: "WETH", + contractAddress: WETH_CONTRACT, + holders: 850000, + value: "1.2500", + exactValue: "1.25", + }); + const result = filterTransactions([tx], filters()); + expect(result.transactions).toEqual([tx]); + }); + + test("a mixed history keeps exactly the legitimate entries, in order", () => { + const ethIn = nativeTx(); + const usdcIn = tokenTx(); + const ethOut = legitimateEthSend(); + const result = filterTransactions( + [ + fakeEthTokenTransfer(), + ethIn, + nativeDustTransfer(), + usdcIn, + tokenTx({ + hash: "0x" + "9".repeat(64), + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 0, + }), + ethOut, + ], + filters(), + ); + expect(hashesOf(result)).toEqual([ + ethIn.hash, + usdcIn.hash, + ethOut.hash, + ]); + }); + + test("surviving entries are the same objects, unmodified", () => { + const tx = tokenTx(); + const before = JSON.stringify(tx); + const result = filterTransactions([tx], filters()); + expect(result.transactions[0]).toBe(tx); + expect(JSON.stringify(tx)).toBe(before); + }); + + test("an empty history yields empty results", () => { + const result = filterTransactions([], filters()); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// fetchRecentTransactions owns the per-address merge of normal transactions +// with ERC-20 transfers. (The cross-address merge Home performs lives in +// src/popup/views/home.js.) debugFetch is mocked, so these tests exercise the +// merge logic against static fixture payloads with no network involved. +// --------------------------------------------------------------------------- + +const BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; +const TS = "2026-02-27T12:00:00.000Z"; +const TS_EPOCH = Math.floor(Date.parse(TS) / 1000); + +function respondWith(txItems, tokenTransferItems) { + debugFetch.mockImplementation(async (url) => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => + url.includes("/token-transfers") + ? { items: tokenTransferItems } + : { items: txItems }, + })); +} + +describe("fetchRecentTransactions merge and dedup", () => { + beforeEach(() => { + debugFetch.mockReset(); + }); + + test("queries only the two Blockscout endpoints for the address", async () => { + respondWith([], []); + await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + expect(debugFetch).toHaveBeenCalledTimes(2); + const urls = debugFetch.mock.calls.map((c) => c[0]); + expect(urls).toContain( + BLOCKSCOUT + "/addresses/" + VICTIM + "/transactions", + ); + expect(urls).toContain( + BLOCKSCOUT + + "/addresses/" + + VICTIM + + "/token-transfers?type=ERC-20", + ); + }); + + test("a swap consolidates its token transfers into the single tx entry", async () => { + const hash = "0x" + "1".repeat(64); + respondWith( + [ + { + hash: hash, + block_number: 21000010, + timestamp: TS, + from: { hash: VICTIM }, + to: { + hash: "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + is_contract: true, + }, + value: "0", + method: "execute", + status: "ok", + }, + ], + [ + { + transaction_hash: hash, + block_number: 21000010, + timestamp: TS, + from: { hash: VICTIM }, + to: { hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af" }, + total: { value: "1500500000", decimals: "6" }, + token: { + symbol: "USDC", + address_hash: USDC_CONTRACT, + holders_count: "3500000", + }, + }, + { + transaction_hash: hash, + block_number: 21000010, + timestamp: TS, + from: { + hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af", + }, + to: { hash: VICTIM }, + total: { value: "250000000000000000", decimals: "18" }, + token: { + symbol: "WETH", + address_hash: WETH_CONTRACT, + holders_count: "850000", + }, + }, + ], + ); + + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + expect(txs).toHaveLength(1); + const merged = txs[0]; + // The received leg (the swap output) supplies the display amount. + expect(merged.symbol).toBe("WETH"); + expect(merged.value).toBe("0.2500"); + expect(merged.contractAddress).toBe(WETH_CONTRACT); + expect(merged.holders).toBe(850000); + // The user's own address and the contract they called are preserved, + // not the router addresses from the token transfer legs. + expect(merged.from).toBe(VICTIM); + expect(merged.to).toBe("0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad"); + expect(merged.direction).toBe("contract"); + expect(merged.directionLabel).toBe("Swap"); + expect(merged.timestamp).toBe(TS_EPOCH); + }); + + test("a token transfer with no matching transaction gets its own entry", async () => { + respondWith( + [], + [ + { + transaction_hash: "0x" + "2".repeat(64), + block_number: 21000020, + timestamp: TS, + from: { hash: ORDINARY_PEER }, + to: { hash: VICTIM }, + total: { value: "1500500000", decimals: "6" }, + token: { + symbol: "USDC", + address_hash: USDC_CONTRACT, + holders_count: "3500000", + }, + }, + ], + ); + + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + expect(txs).toHaveLength(1); + expect(txs[0].symbol).toBe("USDC"); + expect(txs[0].value).toBe("1500.5000"); + expect(txs[0].direction).toBe("received"); + expect(txs[0].contractAddress).toBe(USDC_CONTRACT); + expect(txs[0].holders).toBe(3500000); + }); + + test("two transfers of the same token in one transaction collapse to one entry", async () => { + const hash = "0x" + "3".repeat(64); + const leg = (value) => ({ + transaction_hash: hash, + block_number: 21000030, + timestamp: TS, + from: { hash: ORDINARY_PEER }, + to: { hash: VICTIM }, + total: { value: value, decimals: "6" }, + token: { + symbol: "USDC", + address_hash: USDC_CONTRACT, + holders_count: "3500000", + }, + }); + respondWith([], [leg("1000000"), leg("2000000")]); + + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + expect(txs).toHaveLength(1); + // Keyed by hash plus contract, so the later leg wins. + expect(txs[0].exactValue).toBe("2.0"); + }); + + test("two different tokens in one transaction stay as separate entries", async () => { + const hash = "0x" + "4".repeat(64); + respondWith( + [], + [ + { + transaction_hash: hash, + block_number: 21000040, + timestamp: TS, + from: { hash: ORDINARY_PEER }, + to: { hash: VICTIM }, + total: { value: "1000000", decimals: "6" }, + token: { + symbol: "USDC", + address_hash: USDC_CONTRACT, + holders_count: "3500000", + }, + }, + { + transaction_hash: hash, + block_number: 21000040, + timestamp: TS, + from: { hash: ORDINARY_PEER }, + to: { hash: VICTIM }, + total: { value: "1000000000000000000", decimals: "18" }, + token: { + symbol: "WETH", + address_hash: WETH_CONTRACT, + holders_count: "850000", + }, + }, + ], + ); + + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + 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 () => { + const hash = "0x" + "5".repeat(64); + respondWith( + [ + { + hash: hash, + block_number: 21000050, + timestamp: TS, + from: { hash: VICTIM }, + to: { hash: USDC_CONTRACT, is_contract: true }, + value: "0", + method: "transfer", + status: "ok", + }, + ], + [ + { + transaction_hash: hash, + block_number: 21000050, + timestamp: TS, + from: { hash: VICTIM }, + to: { hash: ORDINARY_PEER }, + total: { value: "1000000", decimals: "6" }, + token: { + symbol: "USDC", + address_hash: USDC_CONTRACT, + holders_count: "3500000", + }, + }, + ], + ); + + 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. + const kept = filterTransactions(txs, filters()).transactions; + expect(kept).toHaveLength(2); + }); + + test("entries are sorted by block number descending and capped at count", async () => { + const item = (n) => ({ + hash: "0x" + String(n).repeat(64), + block_number: 21000000 + n, + timestamp: TS, + from: { hash: ORDINARY_PEER }, + to: { hash: VICTIM, is_contract: false }, + value: "1000000000000000000", + method: null, + status: "ok", + }); + respondWith([item(6), item(8), item(7)], []); + + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2); + expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]); + }); + + test("the fake token transfer survives fetching and is then filtered", async () => { + respondWith( + [], + [ + { + transaction_hash: FAKE_ETH_TRANSFER_HASH, + block_number: 21000060, + timestamp: TS, + from: { hash: VICTIM }, + to: { hash: TOKEN_SCAM_LOOKALIKE }, + total: { value: "5000000000000000", decimals: "18" }, + token: { + symbol: "ETH", + address_hash: FAKE_ETH_CONTRACT, + holders_count: "0", + }, + }, + ], + ); + + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + expect(txs).toHaveLength(1); + expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT); + expect(txs[0].holders).toBe(0); + + const result = filterTransactions(txs, filters()); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); + }); + + test("failed responses yield an empty list rather than throwing", async () => { + debugFetch.mockImplementation(async () => ({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: async () => { + throw new Error("body must not be read on a failed response"); + }, + })); + await expect( + fetchRecentTransactions(VICTIM, BLOCKSCOUT), + ).resolves.toEqual([]); + }); + + test("no test in this file performed a network request", () => { + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); -- 2.49.1 From ad9162d0576c530707d7f29350ed6e61d1a156f4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 10 Aug 2026 15:59:30 +0200 Subject: [PATCH 03/44] security: decrypt and sign dApp approvals in the popup (closes #157) The password no longer crosses the extension messaging boundary: the popup decrypts and signs, and sends only the raw signed transaction or the signature. The background re-derives the signer from the artifact and checks it against the approval it holds before broadcasting, so it is not a blind relay. --- TODO.md | 4 + src/background/index.js | 107 +++-------- src/popup/views/approval.js | 245 +++++++++++++++++++----- src/shared/approvalVerify.js | 124 ++++++++++++ tests/approvalVerify.test.js | 355 +++++++++++++++++++++++++++++++++++ 5 files changed, 712 insertions(+), 123 deletions(-) create mode 100644 src/shared/approvalVerify.js create mode 100644 tests/approvalVerify.test.js diff --git a/TODO.md b/TODO.md index 3f1366f..3fd5fae 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,10 @@ review. # Completed Steps +- 2026-08-09: dApp approval signing moved into the popup — the password no + longer crosses the extension messaging boundary; the background broadcasts and + resolves approvals only, and verifies the signed artifact against the approval + it holds (#157). - 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` / `script/test-e2e`) driving the real popup with all network intercepted, plus the two used-but-not-imported crashes it caught: AddToken unreachable (#150) diff --git a/src/background/index.js b/src/background/index.js index 9a4879f..159ed55 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -5,7 +5,6 @@ const { DEFAULT_RPC_URL } = require("../shared/constants"); const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const { onChainSwitch } = require("../shared/chainSwitch"); -const { getBytes } = require("ethers"); const { state, loadState, @@ -14,8 +13,7 @@ const { } = require("../shared/state"); const { refreshBalances, getProvider } = require("../shared/balances"); const { debugFetch } = require("../shared/log"); -const { decryptWithPassword } = require("../shared/vault"); -const { getSignerForAddress } = require("../shared/wallet"); +const { verifySignedTx, verifySignature } = require("../shared/approvalVerify"); const { isPhishingDomain, updatePhishingList, @@ -725,39 +723,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { return true; } + // The popup signs; it reports back here when it could not. Fail the + // request the same way this handler used to when it did the signing. + if (msg.error) { + approval.resolve({ error: { message: msg.error } }); + sendResponse({ error: msg.error }); + return false; + } + (async () => { try { await loadState(); const activeAddress = await getActiveAddress(); - let wallet, addrIndex; - for (const w of state.wallets) { - for (let i = 0; i < w.addresses.length; i++) { - if (w.addresses[i].address === activeAddress) { - wallet = w; - addrIndex = i; - break; - } - } - if (wallet) break; - } - if (!wallet) throw new Error("Wallet not found"); - // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage - let decrypted = await decryptWithPassword( - wallet.encryptedSecret, - msg.password, + // The popup holds the secret, but the background stays the + // authority on what is broadcast: the raw transaction must be + // the approved one, signed by the approved address. + verifySignedTx( + msg.rawSignedTx, + approval.txParams, + activeAddress, ); - const signer = getSignerForAddress( - wallet, - addrIndex, - decrypted, - ); - // Best-effort: clear decrypted secret after use. - // Note: JS strings are immutable; this nulls the reference but - // the original string may persist in memory until GC. - decrypted = null; const provider = getProvider(state.rpcUrl); - const connected = signer.connect(provider); - const tx = await connected.sendTransaction(approval.txParams); + const tx = await provider.broadcastTransaction(msg.rawSignedTx); approval.resolve({ txHash: tx.hash }); sendResponse({ txHash: tx.hash }); } catch (e) { @@ -784,55 +771,23 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { return true; } + // The popup signs; it reports back here when it could not. Fail the + // request the same way this handler used to when it did the signing. + if (msg.error) { + approval.resolve({ error: { message: msg.error } }); + sendResponse({ error: msg.error }); + return false; + } + (async () => { try { - await loadState(); const activeAddress = await getActiveAddress(); - let wallet, addrIndex; - for (const w of state.wallets) { - for (let i = 0; i < w.addresses.length; i++) { - if (w.addresses[i].address === activeAddress) { - wallet = w; - addrIndex = i; - break; - } - } - if (wallet) break; - } - if (!wallet) throw new Error("Wallet not found"); - // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage - let decrypted = await decryptWithPassword( - wallet.encryptedSecret, - msg.password, - ); - const signer = getSignerForAddress( - wallet, - addrIndex, - decrypted, - ); - // Best-effort: clear decrypted secret after use. - // Note: JS strings are immutable; this nulls the reference but - // the original string may persist in memory until GC. - decrypted = null; - - const sp = approval.signParams; - let signature; - - if (sp.method === "personal_sign" || sp.method === "eth_sign") { - signature = await signer.signMessage(getBytes(sp.message)); - } else { - // eth_signTypedData_v4 / eth_signTypedData - const typedData = JSON.parse(sp.typedData); - const { domain, types, message } = typedData; - // ethers handles EIP712Domain internally - delete types.EIP712Domain; - signature = await signer.signTypedData( - domain, - types, - message, - ); - } - + // The popup holds the secret, but the background stays the + // authority on what is handed back to the page: the signature + // must cover the approved payload and recover to the approved + // address. + const signature = msg.signature; + verifySignature(approval.signParams, signature, activeAddress); approval.resolve({ signature }); sendResponse({ signature }); } catch (e) { diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 425a6f4..c9cc740 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -9,10 +9,19 @@ const { attachCopyHandlers, } = require("./helpers"); const { state, saveState, currentNetwork } = require("../../shared/state"); -const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers"); +const { + formatEther, + formatUnits, + getBytes, + Interface, + toUtf8String, +} = require("ethers"); const { getPrice, formatUsd } = require("../../shared/prices"); const { ERC20_ABI } = require("../../shared/constants"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); +const { decryptWithPassword } = require("../../shared/vault"); +const { getSignerForAddress } = require("../../shared/wallet"); +const { getProvider } = require("../../shared/balances"); const txStatus = require("./txStatus"); const uniswap = require("../../shared/uniswap"); const runtime = @@ -153,6 +162,8 @@ function showTxApproval(details) { details.isPhishingDomain, ); + pendingTxParams = details.txParams; + const toAddr = details.txParams.to; const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null; const ethValue = formatEther(details.txParams.value || "0"); @@ -326,6 +337,7 @@ function showSignApproval(details) { ); const sp = details.signParams; + pendingSignParams = sp; $("approve-sign-hostname").textContent = details.hostname; $("approve-sign-from").innerHTML = approvalAddressHtml(sp.from); @@ -401,6 +413,36 @@ function show(id) { let approvalId = null; let pendingTxDetails = null; +// The exact parameters shown to the user, kept so the popup signs what it +// displayed rather than re-fetching anything at approval time. Both are +// repopulated by show() when the popup is closed and reopened. +let pendingTxParams = null; +let pendingSignParams = null; + +// Approve buttons stay disabled and muted while the popup derives the key and +// signs, which is slow enough (Argon2id) that a double click is likely. +function setTxButtonBusy(busy) { + $("btn-approve-tx").disabled = busy; + $("btn-approve-tx").classList.toggle("text-muted", busy); +} + +function setSignButtonBusy(busy) { + $("btn-approve-sign").disabled = busy; + $("btn-approve-sign").classList.toggle("text-muted", busy); +} + +// Locate the wallet and the address index owning the currently active +// address. Returns null when no wallet holds it. +function findActiveWallet() { + for (const wallet of state.wallets) { + for (let i = 0; i < wallet.addresses.length; i++) { + if (wallet.addresses[i].address === state.activeAddress) { + return { wallet, addrIndex: i }; + } + } + } + return null; +} function init(ctx) { $("approve-remember").addEventListener("change", async () => { @@ -430,34 +472,86 @@ function init(ctx) { window.close(); }); - $("btn-approve-tx").addEventListener("click", () => { - const password = $("approve-tx-password").value; + $("btn-approve-tx").addEventListener("click", async () => { + let password = $("approve-tx-password").value; if (!password) { showError("approve-tx-error", "Please enter your password."); return; } hideError("approve-tx-error"); - $("btn-approve-tx").disabled = true; - $("btn-approve-tx").classList.add("text-muted"); + setTxButtonBusy(true); - runtime.sendMessage( - { - type: "AUTISTMASK_TX_RESPONSE", - id: approvalId, - approved: true, - // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage - password: password, - }, - (response) => { - if (response && response.txHash) { - txStatus.showWait(pendingTxDetails, response.txHash); - } else { - const msg = - (response && response.error) || "Transaction failed."; - txStatus.showError(pendingTxDetails, null, msg); - } - }, - ); + const active = findActiveWallet(); + if (!active) { + password = null; + showError( + "approve-tx-error", + "No wallet was found for the active address.", + ); + setTxButtonBusy(false); + return; + } + + // Decrypt here, in the popup. The password must never cross the + // extension messaging boundary; only the signed transaction does. + let decryptedSecret; + try { + decryptedSecret = await decryptWithPassword( + active.wallet.encryptedSecret, + password, + ); + } catch { + showError( + "approve-tx-error", + "That password is incorrect. Please try again.", + ); + setTxButtonBusy(false); + return; + } finally { + // Best-effort: drop the password as soon as the key derivation + // is done. Note that JS strings are immutable; this clears the + // reference but the original string may persist until GC. + password = null; + } + + const payload = { + type: "AUTISTMASK_TX_RESPONSE", + id: approvalId, + approved: true, + }; + try { + const signer = getSignerForAddress( + active.wallet, + active.addrIndex, + decryptedSecret, + ); + const provider = getProvider(state.rpcUrl); + const connected = signer.connect(provider); + // This is the sequence ethers' own sendTransaction() runs + // internally, so nonce, gas, fee and chain id population are + // identical to when the background did the signing. + const populated = + await connected.populateTransaction(pendingTxParams); + delete populated.from; + payload.rawSignedTx = await connected.signTransaction(populated); + } catch (e) { + payload.error = + e.shortMessage || e.message || "Transaction signing failed."; + } finally { + // Best-effort: clear the decrypted secret after use, with the + // same immutability caveat as the password above. + decryptedSecret = null; + } + + runtime.sendMessage(payload, (response) => { + if (response && response.txHash) { + txStatus.showWait(pendingTxDetails, response.txHash); + } else { + const msg = + (response && response.error) || "Transaction failed."; + txStatus.showError(pendingTxDetails, null, msg); + } + }); }); $("btn-reject-tx").addEventListener("click", () => { @@ -469,36 +563,93 @@ function init(ctx) { window.close(); }); - $("btn-approve-sign").addEventListener("click", () => { - const password = $("approve-sign-password").value; + $("btn-approve-sign").addEventListener("click", async () => { + let password = $("approve-sign-password").value; if (!password) { showError("approve-sign-error", "Please enter your password."); return; } hideError("approve-sign-error"); - $("btn-approve-sign").disabled = true; - $("btn-approve-sign").classList.add("text-muted"); + setSignButtonBusy(true); - runtime.sendMessage( - { - type: "AUTISTMASK_SIGN_RESPONSE", - id: approvalId, - approved: true, - // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage - password: password, - }, - (response) => { - if (response && response.signature) { - window.close(); - } else { - const msg = - (response && response.error) || "Signing failed."; - showError("approve-sign-error", msg); - $("btn-approve-sign").disabled = false; - $("btn-approve-sign").classList.remove("text-muted"); - } - }, - ); + const active = findActiveWallet(); + if (!active) { + password = null; + showError( + "approve-sign-error", + "No wallet was found for the active address.", + ); + setSignButtonBusy(false); + return; + } + + // Decrypt here, in the popup. The password must never cross the + // extension messaging boundary; only the signature does. + let decryptedSecret; + try { + decryptedSecret = await decryptWithPassword( + active.wallet.encryptedSecret, + password, + ); + } catch { + showError( + "approve-sign-error", + "That password is incorrect. Please try again.", + ); + setSignButtonBusy(false); + return; + } finally { + // Best-effort: drop the password as soon as the key derivation + // is done. Note that JS strings are immutable; this clears the + // reference but the original string may persist until GC. + password = null; + } + + const payload = { + type: "AUTISTMASK_SIGN_RESPONSE", + id: approvalId, + approved: true, + }; + try { + const signer = getSignerForAddress( + active.wallet, + active.addrIndex, + decryptedSecret, + ); + const sp = pendingSignParams; + if (sp.method === "personal_sign" || sp.method === "eth_sign") { + payload.signature = await signer.signMessage( + getBytes(sp.message), + ); + } else { + // eth_signTypedData_v4 / eth_signTypedData + const typedData = JSON.parse(sp.typedData); + const { domain, types, message } = typedData; + // ethers handles EIP712Domain internally + delete types.EIP712Domain; + payload.signature = await signer.signTypedData( + domain, + types, + message, + ); + } + } catch (e) { + payload.error = e.shortMessage || e.message || "Signing failed."; + } finally { + // Best-effort: clear the decrypted secret after use, with the + // same immutability caveat as the password above. + decryptedSecret = null; + } + + runtime.sendMessage(payload, (response) => { + if (response && response.signature) { + window.close(); + } else { + const msg = (response && response.error) || "Signing failed."; + showError("approve-sign-error", msg); + setSignButtonBusy(false); + } + }); }); $("btn-reject-sign").addEventListener("click", () => { diff --git a/src/shared/approvalVerify.js b/src/shared/approvalVerify.js new file mode 100644 index 0000000..4004425 --- /dev/null +++ b/src/shared/approvalVerify.js @@ -0,0 +1,124 @@ +// Verification of the signed artifacts produced by the approval popup. +// +// Signing happens in the popup, where the password is entered; the background +// only broadcasts the raw transaction and resolves the pending approval back +// to the requesting page. So that moving the signing out of the background +// does not turn the background into a blind relay, the background re-derives +// the signer from the artifact and checks it against the approval it is +// holding before acting on it. All recovery is delegated to ethers. +// +// Every failure message is a full sentence, because these strings are shown to +// the user and returned to the dApp. + +const { + Transaction, + getAddress, + getBytes, + verifyMessage, + verifyTypedData, +} = require("ethers"); + +// Case-insensitive address comparison that tolerates absent values on either +// side. Two absent addresses compare equal (contract creation has no `to`). +function sameAddress(a, b) { + const aMissing = a === null || a === undefined || a === ""; + const bMissing = b === null || b === undefined || b === ""; + if (aMissing || bMissing) return aMissing && bMissing; + try { + return getAddress(a) === getAddress(b); + } catch { + return String(a).toLowerCase() === String(b).toLowerCase(); + } +} + +// Normalize a transaction value (hex string, decimal string, number or +// bigint) to a bigint. An absent value is zero, matching ethers. +function normalizeValue(v) { + if (v === null || v === undefined || v === "") return 0n; + return BigInt(v); +} + +// Normalize call data to a lowercase hex string. Absent data is "0x". +function normalizeData(v) { + if (v === null || v === undefined || v === "" || v === "0x") return "0x"; + return String(v).toLowerCase(); +} + +// Assert that a raw signed transaction is the transaction the user approved, +// signed by the address the approval was raised for. Returns the parsed +// ethers Transaction on success, throws otherwise. +function verifySignedTx(rawSignedTx, txParams, expectedFrom) { + if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) { + throw new Error("The signed transaction is missing or malformed."); + } + + let parsed; + try { + parsed = Transaction.from(rawSignedTx); + } catch { + throw new Error("The signed transaction could not be decoded."); + } + + if (!parsed.from) { + throw new Error("The signed transaction carries no valid signature."); + } + if (!sameAddress(parsed.from, expectedFrom)) { + throw new Error( + "The signed transaction was signed by a different address than the one that was approved.", + ); + } + if (!sameAddress(parsed.to, txParams.to)) { + throw new Error( + "The signed transaction does not go to the approved recipient.", + ); + } + if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) { + throw new Error( + "The signed transaction does not carry the approved value.", + ); + } + if (normalizeData(parsed.data) !== normalizeData(txParams.data)) { + throw new Error( + "The signed transaction does not carry the approved call data.", + ); + } + + return parsed; +} + +// Assert that a signature over the approved message or typed data was +// produced by the address the approval was raised for. Returns the recovered +// address on success, throws otherwise. +function verifySignature(signParams, signature, expectedFrom) { + if (typeof signature !== "string" || !signature.startsWith("0x")) { + throw new Error("The signature is missing or malformed."); + } + + let recovered; + try { + if ( + signParams.method === "personal_sign" || + signParams.method === "eth_sign" + ) { + recovered = verifyMessage(getBytes(signParams.message), signature); + } else { + const typedData = JSON.parse(signParams.typedData); + const { domain, types, message } = typedData; + // ethers derives EIP712Domain itself and rejects it as an input. + delete types.EIP712Domain; + recovered = verifyTypedData(domain, types, message, signature); + } + } catch { + throw new Error("The signature could not be verified."); + } + + if (!sameAddress(recovered, expectedFrom)) { + throw new Error( + "The signature was produced by a different address than the one that was approved.", + ); + } + + return recovered; +} + +module.exports = { verifySignedTx, verifySignature, sameAddress }; diff --git a/tests/approvalVerify.test.js b/tests/approvalVerify.test.js new file mode 100644 index 0000000..1146416 --- /dev/null +++ b/tests/approvalVerify.test.js @@ -0,0 +1,355 @@ +const { Network, Transaction, Wallet } = require("ethers"); +const { + verifySignedTx, + verifySignature, + sameAddress, +} = require("../src/shared/approvalVerify"); +const { getSignerForAddress } = require("../src/shared/wallet"); + +// Fixed test keys — never used for anything but these tests. +const SIGNER_KEY = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +const OTHER_KEY = + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; + +const signer = new Wallet(SIGNER_KEY); +const other = new Wallet(OTHER_KEY); + +const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + +// Approved parameters as a dApp would supply them over eth_sendTransaction. +const TX_PARAMS = { + from: signer.address, + to: RECIPIENT, + value: "0x2386f26fc10000", + data: "0xdeadbeef", + gas: "0x5208", +}; + +// Build a signable transaction from approved params. The popup does the same +// thing through populateTransaction(); here the fields are fixed so the test +// needs no provider. +function txFor(params) { + return { + chainId: 1, + nonce: 7, + gasLimit: 100000n, + maxFeePerGas: 2000000000n, + maxPriorityFeePerGas: 1000000000n, + type: 2, + to: params.to, + value: params.value === undefined ? 0n : BigInt(params.value), + data: params.data || "0x", + }; +} + +async function signedFor(params, withWallet) { + return (withWallet || signer).signTransaction(txFor(params)); +} + +describe("sameAddress", () => { + test("compares checksummed and lowercase forms as equal", () => { + expect(sameAddress(RECIPIENT, RECIPIENT.toLowerCase())).toBe(true); + }); + + test("treats two absent addresses as equal (contract creation)", () => { + expect(sameAddress(null, undefined)).toBe(true); + expect(sameAddress("", null)).toBe(true); + }); + + test("treats one absent address as unequal", () => { + expect(sameAddress(RECIPIENT, null)).toBe(false); + expect(sameAddress(null, RECIPIENT)).toBe(false); + }); + + test("does not throw on values that are not addresses", () => { + expect(sameAddress("not-an-address", RECIPIENT)).toBe(false); + }); +}); + +describe("verifySignedTx", () => { + test("accepts the approved transaction signed by the approved address", async () => { + const raw = await signedFor(TX_PARAMS); + const parsed = verifySignedTx(raw, TX_PARAMS, signer.address); + expect(parsed.from).toBe(signer.address); + expect(parsed.hash).toBe(Transaction.from(raw).hash); + }); + + test("accepts a contract creation with no recipient", async () => { + const params = { to: undefined, value: "0x0", data: "0x600160005500" }; + const raw = await signedFor(params); + expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow(); + }); + + test("accepts an absent value as zero", async () => { + const approved = { to: RECIPIENT, data: "0x" }; + const raw = await signedFor(approved); + expect(() => + verifySignedTx(raw, approved, signer.address), + ).not.toThrow(); + }); + + test("accepts call data whose case differs from the approval", async () => { + const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" }; + const raw = await signedFor(approved); + expect(() => + verifySignedTx(raw, approved, signer.address), + ).not.toThrow(); + }); + + test("rejects a swapped recipient", async () => { + const raw = await signedFor({ + ...TX_PARAMS, + to: OTHER_RECIPIENT, + }); + expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( + /approved recipient/, + ); + }); + + test("rejects an inflated value", async () => { + const raw = await signedFor({ + ...TX_PARAMS, + value: "0x4563918244f40000", + }); + expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( + /approved value/, + ); + }); + + test("rejects substituted call data", async () => { + const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" }); + expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( + /approved call data/, + ); + }); + + test("rejects a transaction signed by a different address", async () => { + const raw = await signedFor(TX_PARAMS, other); + expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( + /different address/, + ); + }); + + test("rejects an unsigned transaction", () => { + const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized; + expect(() => + verifySignedTx(unsigned, TX_PARAMS, signer.address), + ).toThrow(/no valid signature/); + }); + + test("rejects a missing or malformed payload", () => { + expect(() => + verifySignedTx(undefined, TX_PARAMS, signer.address), + ).toThrow(/missing or malformed/); + expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow( + /missing or malformed/, + ); + expect(() => + verifySignedTx("0xc0ffee", TX_PARAMS, signer.address), + ).toThrow(/could not be decoded/); + }); + + test("every rejection message is a full sentence", async () => { + const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT }); + try { + verifySignedTx(raw, TX_PARAMS, signer.address); + throw new Error("expected a rejection"); + } catch (e) { + expect(e.message).toMatch(/^[A-Z].*\.$/); + } + }); +}); + +const TYPED_DATA = JSON.stringify({ + domain: { + name: "AutistMask Test", + version: "1", + chainId: 1, + verifyingContract: OTHER_RECIPIENT, + }, + primaryType: "Mail", + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Mail: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "contents", type: "string" }, + ], + }, + message: { + from: signer.address, + to: RECIPIENT, + contents: "hello", + }, +}); + +describe("verifySignature", () => { + // "Hello AutistMask" as the hex string a dApp passes to personal_sign. + const MESSAGE = "0x48656c6c6f204175746973744d61736b"; + const personalParams = { + method: "personal_sign", + message: MESSAGE, + from: signer.address, + }; + const typedParams = { + method: "eth_signTypedData_v4", + typedData: TYPED_DATA, + from: signer.address, + }; + + async function signPersonal(withWallet) { + return (withWallet || signer).signMessage( + Buffer.from(MESSAGE.slice(2), "hex"), + ); + } + + async function signTyped(withWallet) { + const { domain, types, message } = JSON.parse(TYPED_DATA); + delete types.EIP712Domain; + return (withWallet || signer).signTypedData(domain, types, message); + } + + test("accepts a personal_sign signature from the approved address", async () => { + const signature = await signPersonal(); + expect(verifySignature(personalParams, signature, signer.address)).toBe( + signer.address, + ); + }); + + test("accepts an eth_sign signature the same way", async () => { + const signature = await signPersonal(); + const params = { ...personalParams, method: "eth_sign" }; + expect(() => + verifySignature(params, signature, signer.address), + ).not.toThrow(); + }); + + test("accepts a typed data signature from the approved address", async () => { + const signature = await signTyped(); + expect(verifySignature(typedParams, signature, signer.address)).toBe( + signer.address, + ); + }); + + test("does not mutate the approved typed data while verifying", async () => { + const signature = await signTyped(); + const before = typedParams.typedData; + verifySignature(typedParams, signature, signer.address); + expect(typedParams.typedData).toBe(before); + expect( + JSON.parse(typedParams.typedData).types.EIP712Domain, + ).toBeDefined(); + }); + + test("rejects a personal_sign signature from a different address", async () => { + const signature = await signPersonal(other); + expect(() => + verifySignature(personalParams, signature, signer.address), + ).toThrow(/different address/); + }); + + test("rejects a typed data signature from a different address", async () => { + const signature = await signTyped(other); + expect(() => + verifySignature(typedParams, signature, signer.address), + ).toThrow(/different address/); + }); + + test("rejects a signature over a different message", async () => { + const signature = await signer.signMessage( + Buffer.from("00112233", "hex"), + ); + expect(() => + verifySignature(personalParams, signature, signer.address), + ).toThrow(/different address/); + }); + + test("rejects a missing or malformed signature", async () => { + expect(() => + verifySignature(personalParams, undefined, signer.address), + ).toThrow(/missing or malformed/); + expect(() => + verifySignature(personalParams, "0x1234", signer.address), + ).toThrow(/could not be verified/); + }); +}); + +// End-to-end over the messaging boundary, without a browser: run the exact +// sequence the approval popup runs, then hand the artifact to the exact check +// the background runs before it broadcasts or resolves. Only what the popup +// puts on the wire is passed along, so this also pins down that the wire +// payload is sufficient on its own. +describe("popup signing sequence to background verification", () => { + // Stand-in for the JSON-RPC provider. populateTransaction only needs the + // nonce, the gas estimate, the network and the fee data. + const fakeProvider = { + getNetwork: async () => Network.from(1), + getTransactionCount: async () => 7, + estimateGas: async () => 21000n, + getFeeData: async () => ({ + gasPrice: 2000000000n, + maxFeePerGas: 2000000000n, + maxPriorityFeePerGas: 1000000000n, + }), + }; + + // A private-key wallet as it is persisted in state, so the test goes + // through getSignerForAddress() the way the popup does. + const walletData = { type: "privkey" }; + + async function popupSignsTx(txParams) { + const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY); + const connected = localSigner.connect(fakeProvider); + const populated = await connected.populateTransaction(txParams); + delete populated.from; + return connected.signTransaction(populated); + } + + test("a populated, signed transaction is accepted and broadcastable", async () => { + const rawSignedTx = await popupSignsTx(TX_PARAMS); + const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address); + expect(parsed.nonce).toBe(7); + expect(parsed.chainId).toBe(1n); + expect(parsed.gasLimit).toBe(21000n); + expect(parsed.to).toBe(RECIPIENT); + expect(parsed.value).toBe(BigInt(TX_PARAMS.value)); + expect(parsed.data).toBe(TX_PARAMS.data); + expect(parsed.signature).not.toBeNull(); + }); + + test("the wire payload carries no password and no secret", async () => { + const rawSignedTx = await popupSignsTx(TX_PARAMS); + const payload = { + type: "AUTISTMASK_TX_RESPONSE", + id: "test-approval-id", + approved: true, + rawSignedTx, + }; + expect(Object.keys(payload).sort()).toEqual([ + "approved", + "id", + "rawSignedTx", + "type", + ]); + const wire = JSON.stringify(payload).toLowerCase(); + expect(wire).not.toContain("password"); + expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase()); + }); + + test("the background rejects a transaction the popup did not approve", async () => { + const rawSignedTx = await popupSignsTx({ + ...TX_PARAMS, + to: OTHER_RECIPIENT, + }); + expect(() => + verifySignedTx(rawSignedTx, TX_PARAMS, signer.address), + ).toThrow(/approved recipient/); + }); +}); -- 2.49.1 From e9fa8bec471a317638361a354acafeda281bb26b Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 10 Aug 2026 16:15:22 +0200 Subject: [PATCH 04/44] build: assert DEBUG is off in every emitted bundle as a post-build check (closes #170) build.js records which emitted bundles contain src/shared/constants.js, and constants.js carries a marker constant-folded from DEBUG itself. script/verify-build cross-checks the two and fails on every way of not knowing, so deleting the __BUILD_DEBUG__ define now breaks the build instead of shipping a live debug branch. --- Makefile | 9 ++- README.md | 13 ++++ TODO.md | 3 + build.js | 129 ++++++++++++++++++++++++------------- script/verify-build | 136 ++++++++++++++++++++++++++++++++++++++++ src/shared/constants.js | 16 +++++ tests/constants.test.js | 20 ++++++ 7 files changed, 281 insertions(+), 45 deletions(-) create mode 100755 script/verify-build diff --git a/Makefile b/Makefile index 6a3f3be..cae3066 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build build-debug clean dev +.PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build build-debug verify-build clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -41,6 +41,7 @@ hooks: build: @echo "Building extension..." @yarn run build 2>&1 + @script/verify-build # Development-only build: enables the red DEBUG / INSECURE banner and makes # the hardcoded test recovery phrase the output of wallet creation. Never @@ -48,6 +49,12 @@ build: build-debug: @echo "Building extension (DEBUG)..." @AUTISTMASK_DEBUG=1 yarn run build 2>&1 + @AUTISTMASK_DEBUG=1 script/verify-build + +# Assert the compiled DEBUG state of the bundles already in dist/. Runs at +# the end of build and build-debug; separate target for re-running it alone. +verify-build: + @script/verify-build clean: @rm -rf dist/ diff --git a/README.md b/README.md index 0e8756d..4e5049f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ behavior. The build prints which mode it used. See the distribute a debug build** — every wallet it creates gets the same publicly known test recovery phrase. +Both builds end by running `script/verify-build`, which reads the compiled +`DEBUG` state back out of the emitted bundles and fails the build if it is not +the one that was asked for. The test suite cannot check this: it loads +`src/shared/constants.js` outside a bundle, so it only ever sees the fallback +value. The assertion is on the artifacts because that is where the property +lives. + ## Entrypoints This repository adheres to the @@ -79,6 +86,12 @@ provide: - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check +- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in + `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` + off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of + `make build` and `make build-debug`; fails loudly rather than passing if it + cannot determine a bundle's state. Not part of `make check`, which does not + depend on build artifacts existing. - `script/docker` — build the Docker image tagged via `script/projectname` - `script/cibuild` — CI entrypoint: plain `docker build .` - `script/precommit` — run by the git pre-commit hook; runs `script/check` diff --git a/TODO.md b/TODO.md index 3fd5fae..aee224a 100644 --- a/TODO.md +++ b/TODO.md @@ -32,6 +32,9 @@ review. longer crosses the extension messaging boundary; the background broadcasts and resolves approvals only, and verifies the signed artifact against the approval it holds (#157). +- 2026-08-09: Post-build assertion that every emitted bundle containing + `constants.js` has `DEBUG` compiled off, via `script/verify-build` on the + `make build` path (#170). - 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` / `script/test-e2e`) driving the real popup with all network intercepted, plus the two used-but-not-imported crashes it caught: AddToken unreachable (#150) diff --git a/build.js b/build.js index d738586..0ecdad4 100644 --- a/build.js +++ b/build.js @@ -3,14 +3,43 @@ const path = require("path"); const { execSync } = require("child_process"); const esbuild = require("esbuild"); -const DIST_CHROME = path.join(__dirname, "dist", "chrome"); -const DIST_FIREFOX = path.join(__dirname, "dist", "firefox"); +const DIST = path.join(__dirname, "dist"); +const DIST_CHROME = path.join(DIST, "chrome"); +const DIST_FIREFOX = path.join(DIST, "firefox"); const SRC = path.join(__dirname, "src"); +// The module whose compiled DEBUG state script/verify-build asserts, and the +// manifest naming every emitted bundle that ends up containing it. The +// manifest is derived from esbuild's own dependency graph rather than from a +// hardcoded list, so it tracks the bundle layout instead of rotting with it. +const AUDITED_MODULE = "src/shared/constants.js"; +const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt"); + function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } +// Repo-relative, forward-slashed, so the manifest reads the same on every +// platform and can be consumed by a POSIX shell script without further work. +function repoRelative(p) { + return path.relative(__dirname, p).split(path.sep).join("/"); +} + +// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild +// 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. +function outputsContainingAuditedModule(metafile) { + return Object.entries(metafile.outputs) + .filter(([outFile, info]) => { + if (!outFile.endsWith(".js")) return false; + return Object.keys(info.inputs).some( + (input) => repoRelative(input) === AUDITED_MODULE, + ); + }) + .map(([outFile]) => repoRelative(outFile)); +} + // DEBUG is a build-time flag, off unless explicitly requested. It is the only // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // exact: anything other than the literal "1" (unset, empty, "true", a typo) @@ -72,68 +101,70 @@ async function build() { __BUILD_DATE__: JSON.stringify(buildInfo.buildDate), }; + // Emitted bundles that contain constants.js, accumulated across every + // esbuild run below and written out for script/verify-build. + const auditedBundles = []; + // compile tailwind CSS console.log("Compiling Tailwind CSS..."); const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); - const tailwindOutput = path.join(__dirname, "dist", "styles.css"); - ensureDir(path.join(__dirname, "dist")); + const tailwindOutput = path.join(DIST, "styles.css"); + ensureDir(DIST); + + // Drop any manifest from a previous build before emitting anything, so a + // build that never gets around to writing one cannot be verified against + // a stale list. + fs.rmSync(BUNDLE_MANIFEST, { force: true }); execSync( `npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, { stdio: "inherit" }, ); + // Every bundle goes through here, so metafile collection cannot be + // forgotten when a new entry point is added. + async function bundle(entryPoint, outfile) { + const result = await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + format: "iife", + outfile, + platform: "browser", + target: ["chrome110", "firefox110"], + minify: true, + metafile: true, + define, + }); + auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); + } + for (const distDir of [DIST_CHROME, DIST_FIREFOX]) { ensureDir(path.join(distDir, "src", "popup")); ensureDir(path.join(distDir, "src", "background")); ensureDir(path.join(distDir, "src", "content")); // bundle popup JS with esbuild (inlines ethers, libsodium, etc.) - await esbuild.build({ - entryPoints: [path.join(SRC, "popup", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "popup", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "popup", "index.js"), + path.join(distDir, "src", "popup", "index.js"), + ); // bundle background script - await esbuild.build({ - entryPoints: [path.join(SRC, "background", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "background", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "background", "index.js"), + path.join(distDir, "src", "background", "index.js"), + ); // bundle content script - await esbuild.build({ - entryPoints: [path.join(SRC, "content", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "content", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "content", "index.js"), + path.join(distDir, "src", "content", "index.js"), + ); // bundle inpage script (injected into page context, separate file) - await esbuild.build({ - entryPoints: [path.join(SRC, "content", "inpage.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "content", "inpage.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "content", "inpage.js"), + path.join(distDir, "src", "content", "inpage.js"), + ); // copy popup HTML fs.copyFileSync( @@ -158,6 +189,16 @@ async function build() { path.join(DIST_FIREFOX, "manifest.json"), ); + // Written last so a build that died partway through leaves no manifest + // at all, which script/verify-build treats as a hard failure rather than + // as "nothing to check". + const manifest = [...new Set(auditedBundles)].sort(); + fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join("")); + console.log( + `Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` + + `(listed in ${repoRelative(BUNDLE_MANIFEST)})`, + ); + console.log("Build complete: dist/chrome/ and dist/firefox/"); } diff --git a/script/verify-build b/script/verify-build new file mode 100755 index 0000000..23b1b7c --- /dev/null +++ b/script/verify-build @@ -0,0 +1,136 @@ +#!/bin/sh +# script/verify-build: assert the compiled DEBUG state of the emitted +# bundles. Our own extension to scripts-to-rule-them-all, run at the end of +# make build / make build-debug. +# +# Why this exists: DEBUG makes the publicly committed test recovery phrase the +# output of wallet creation, so a release artifact built with it live hands +# every new wallet to anyone who reads the repo. The test suite cannot see +# this, because it loads src/shared/constants.js outside a bundle and takes +# the fallback branch; the property only exists in the emitted output, so it +# has to be asserted against the emitted output. +# +# What it reads: dist/constants-bundles.txt, written by build.js from +# esbuild's metafile, naming every emitted bundle that contains +# src/shared/constants.js. Each of those must carry exactly one of the two +# BUILD_DEBUG_MARKER literals that constants.js folds down to. +# +# It fails rather than passes whenever it cannot determine a bundle's state. +# Minified output is not a stable contract, so "matched neither form" is not +# evidence of anything and must never read as green. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +MANIFEST="dist/constants-bundles.txt" +MARKER_ON="autistmask-build-debug=on" +MARKER_OFF="autistmask-build-debug=off" + +# Set by read_marker. +MARKER="" + +fail() { + echo "verify-build: FAIL: $*" >&2 + exit 1 +} + +has_marker() { + grep -q -F "$1" "$2" 2>/dev/null +} + +# 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. +read_marker() { + _file="$1" + _on=no + _off=no + if has_marker "$MARKER_ON" "$_file"; then _on=yes; fi + 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__." + fi + if [ "$_on" = no ] && [ "$_off" = no ]; then + fail "$_file carries no debug marker, so its DEBUG state cannot be + determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js + or the emitted output changed shape. Refusing to report success." + fi + + if [ "$_on" = yes ]; then + MARKER="$MARKER_ON" + else + MARKER="$MARKER_OFF" + fi +} + +# 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 +# or short rather than trusting whatever it happens to list. +check_unlisted_bundles() { + _listing="$(find dist -type f -name '*.js' | sort)" + while read -r _file; do + [ -n "$_file" ] || continue + if grep -q -x -F "$_file" "$MANIFEST"; then + continue + fi + if has_marker "$MARKER_ON" "$_file" || + has_marker "$MARKER_OFF" "$_file"; then + fail "$_file carries a debug marker but is absent from $MANIFEST, + so the manifest no longer describes the emitted bundles." + fi + done < { expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); }); + // This does not replace script/verify-build, which is the only thing that + // can see the compiled DEBUG state of a real bundle. It pins the source + // invariant that the marker tracks DEBUG, so the two cannot be edited + // apart and leave verify-build asserting something that is no longer the + // flag the code branches on. + test("build debug marker is derived from DEBUG", () => { + expect(BUILD_DEBUG_MARKER).toBe( + DEBUG ? "autistmask-build-debug=on" : "autistmask-build-debug=off", + ); + }); + + // Outside a bundle there is no __BUILD_DEBUG__ define, and the fallback + // must be the safe one. + test("DEBUG is off when loaded outside a bundle", () => { + expect(DEBUG).toBe(false); + expect(BUILD_DEBUG_MARKER).toBe("autistmask-build-debug=off"); + }); + test("exports ERC-20 ABI with expected functions", () => { expect(Array.isArray(ERC20_ABI)).toBe(true); expect(ERC20_ABI.length).toBeGreaterThan(0); -- 2.49.1 From d93eda31a01a959b0a5fc0bd7859f9b715279d08 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:15:05 +0200 Subject: [PATCH 05/44] docs: rewrite TODO.md workflow for the branch-per-issue model on next (closes #191) --- TODO.md | 104 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/TODO.md b/TODO.md index aee224a..1529c97 100644 --- a/TODO.md +++ b/TODO.md @@ -1,33 +1,57 @@ # Workflow -- branch (from `main`) -- do the work in Next Step -- move Next Step to the top of Completed Steps -- move the top item of Future Steps into Next Step -- commit (`TODO.md` changes in the same commit as the work) -- merge to `main` if the branch is not protected, otherwise open a PR -- push +- `git pull` `next` and cut a branch from it — one branch per issue, named + `issue--`. Never branch from `main`. +- Do the work as one commit whose title ends with ` (closes #N)`, with the + `TODO.md` update in that same commit. +- Move Next Step to the top of Completed Steps; move the top item of Future + Steps into Next Step. +- Run `make fmt`, then `make check`. A feature branch may be red; `next` and + `main` may not. +- Rebase onto current `next` immediately before pushing — other branches land on + `next` continuously — and re-run `make check` after resolving, because a clean + textual merge can still break the build. +- Push the branch and open one PR per issue with base `next`. Never base `main`. +- An independent reviewer who did not write the change gates the merge. On a + passed review the PR is squash-merged into `next`. +- `next` is the branch for the next milestone. It is kept green and mergeable to + `main` at any moment, without notice. +- `main` receives exactly one PR per milestone, from `next`. Releases are tagged + from `main`. # Status -pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. No -other branch is in flight: the settings About well landed as #145 on 2026-07-26 -and scripts-to-rule-them-all landed as #148, so the `scripts/` directory -question is resolved. Full policy file set present. `make check` verified -passing on `main` at `23aeae4` on 2026-08-09. The 1.0.0 backlog is filed as -#149-#168. A real-browser end-to-end suite (`make test-e2e`) now sits alongside -`make check`, which cannot see a runtime `ReferenceError` in a popup view. +pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The +milestone is in flight on `next`; its `next` -> `main` PR is +[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified +green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces +`dist/chrome/` and `dist/firefox/` with every bundle verified to have `DEBUG` +compiled off. + +The backlog lives on the +[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is +authoritative; this file does not duplicate it. Full policy file set present. A +real-browser end-to-end suite (`make test-e2e`) now sits alongside `make check`, +which cannot see a runtime `ReferenceError` in a popup view. # Next Step -Land #149: make `DEBUG` a build-time constant that defaults to off, injected as -the `__BUILD_DEBUG__` esbuild define from `AUTISTMASK_DEBUG=1`, so a plain -`make build` stops handing every newly created wallet the publicly committed -test recovery phrase. Branch `fix/issue-149-debug-build-flag`; PR open, awaiting -review. +Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to +`script/lint`. `make check` is `prettier --check` only today and cannot catch +undefined identifiers, which is how +[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and +[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped. # Completed Steps +- 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)). +- 2026-08-09: `DEBUG` became a build-time constant defaulting to off, injected + as the `__BUILD_DEBUG__` esbuild define and turned on with + `AUTISTMASK_DEBUG=1`, so a plain `make build` no longer hands every newly + created wallet the publicly committed test recovery phrase + ([#149](https://git.eeqj.de/sneak/AutistMask/issues/149)). - 2026-08-09: dApp approval signing moved into the popup — the password no longer crosses the extension messaging boundary; the background broadcasts and resolves approvals only, and verifies the signed artifact against the approval @@ -69,39 +93,15 @@ review. # Future Steps -- Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only - and cannot catch undefined identifiers, which is how #150 and #151 shipped. -- Decide the libsodium backend that actually ships (#182) and delete the single - allowlist entry it owns in `tests/e2e/harness.js`. -- Extend the end-to-end suite to the dApp approval signing path (EIP-1193 - through the real content script, background worker and approval popup). That - path needs a CDP-based route to background-worker console output first: - Playwright exposes no error event for service workers, so an uncaught - exception in the worker cannot fail the run today (worker network traffic is - already covered). Decide separately whether docker-in-docker makes - `make test-e2e` runnable in the Gitea workflow. -- Make the Firefox target functional: Chrome callback APIs are used against the - promise-only `browser` namespace (#153). -- Send and transaction-flow correctness: gas fee excluded from the - insufficient-balance check (#154), WaitTx 60s timeout overwriting a rendered - success screen (#155), last-wallet deletion leaving inconsistent state (#156). -- Security: plaintext password crossing the extension messaging boundary during - dApp approvals (#157); MV3 service worker termination killing the background - refresh and the 24h phishing list update (#158). -- Test the crypto core — `wallet.js` derivation and `vault.js` encryption (#159) - — and the address-poisoning defense in `transactions.js` (#160). -- Wallet features for 1.0: show a wallet's recovery phrase behind the password - (#161), delete an address from an HD wallet (#162). -- Docs: `docs/README.md` contradicts the code on external services and names - competitors (#163); README Screen Map omits three shipped screens (#164). -- Owner decisions: Sepolia support versus "Non-Goals for 1.0", and `isMetaMask` - naming a competitor in shipped code (#165). -- Repo policy compliance sweep: test rerun pattern, `yarn`/`npx`, frozen - lockfile, undocumented Makefile targets (#166). -- Prune the 24 stale remote feature branches (#167). -- Remove dead exports and de-duplicate copy-pasted view helpers (#168). +Only work that has no issue of its own belongs here; everything else is on the +tracker. + - Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC - input validation) before any 1.0rc tag; #149 and #157 are parts of it, but the - review is broader than either. + input validation) before any 1.0rc tag. Individual filed issues are parts of + it, but the review is broader than any of them. +- Decide whether docker-in-docker makes `make test-e2e` runnable in the Gitea + workflow. Extending the suite itself is tracked as + [#183](https://git.eeqj.de/sneak/AutistMask/issues/183) and + [#184](https://git.eeqj.de/sneak/AutistMask/issues/184). - Cut 1.0.0 once the milestone is empty, then continue tagging as milestones land. -- 2.49.1 From b882cede9f588af564bdc26148e0f68876e4aa4f Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:23:06 +0200 Subject: [PATCH 06/44] fix: repair wallet state on delete (closes #156) --- README.md | 2 +- TODO.md | 4 + src/popup/views/deleteWallet.js | 35 ++++----- src/shared/walletDelete.js | 70 +++++++++++++++++ tests/walletDelete.test.js | 129 ++++++++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 src/shared/walletDelete.js create mode 100644 tests/walletDelete.test.js diff --git a/README.md b/README.md index 4e5049f..7c10480 100644 --- a/README.md +++ b/README.md @@ -980,7 +980,7 @@ Currently supported: ### Wallet Management -- [ ] Delete wallet (with confirmation) +- [x] Delete wallet (with confirmation) - [ ] Delete address from HD wallet (with confirmation) - [ ] Show wallet's recovery phrase (requires password) diff --git a/TODO.md b/TODO.md index 1529c97..78c8ceb 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,10 @@ undefined identifiers, which is how # Completed Steps +- 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: `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)). diff --git a/src/popup/views/deleteWallet.js b/src/popup/views/deleteWallet.js index f682e23..0f0381a 100644 --- a/src/popup/views/deleteWallet.js +++ b/src/popup/views/deleteWallet.js @@ -1,6 +1,10 @@ const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); const { state, saveState } = require("../../shared/state"); const { decryptWithPassword } = require("../../shared/vault"); +const { + removeWalletFromState, + broadcastActiveChanged, +} = require("../../shared/walletDelete"); let deleteWalletIndex = null; let ctx = null; @@ -58,35 +62,24 @@ function init(_ctx) { return; } - // Collect addresses to clean up from allowedSites/deniedSites - const addresses = (wallet.addresses || []).map((a) => a.address); - - // Remove wallet - state.wallets.splice(walletIdx, 1); - - // Clean up site permissions for deleted addresses - for (const addr of addresses) { - delete state.allowedSites[addr]; - delete state.deniedSites[addr]; - } + // Remove the wallet and repair selection, permissions and hasWallet + const { activeAddressChanged } = removeWalletFromState( + state, + walletIdx, + ); deleteWalletIndex = null; - if (state.wallets.length === 0) { - // No wallets left — reset selection and show welcome - state.selectedWallet = null; - state.selectedAddress = null; - state.activeAddress = null; + if (!state.hasWallet) { clearViewStack(); await saveState(); + // Save before broadcasting: the background reads the active + // address back out of storage to build accountsChanged. + if (activeAddressChanged) broadcastActiveChanged(); showView("welcome"); } else { - // Switch to first wallet if deleted wallet was active - state.selectedWallet = 0; - state.selectedAddress = 0; - state.activeAddress = - state.wallets[0].addresses[0]?.address || null; await saveState(); + if (activeAddressChanged) broadcastActiveChanged(); // Reset stack to [main] so Settings back goes home. // Use require() lazily to avoid circular dependency // (settings.js requires deleteWallet.js). diff --git a/src/shared/walletDelete.js b/src/shared/walletDelete.js new file mode 100644 index 0000000..dea26ee --- /dev/null +++ b/src/shared/walletDelete.js @@ -0,0 +1,70 @@ +// Wallet deletion state transition, kept out of the view so the selection +// and broadcast rules are testable without a DOM. + +// Remove wallet `walletIdx` from `state` and repair the derived state. +// +// Rules: +// - `hasWallet` tracks whether any wallet remains. +// - Site permissions are dropped for every address of the deleted wallet. +// - `selectedWallet` follows the splice: it is decremented when a wallet +// before it was removed, and falls back to the first remaining wallet's +// first address only when the selection itself was deleted. +// - `activeAddress` is only moved when it belonged to the deleted wallet; +// the fallback is the first remaining wallet's first address, or null +// when no wallet remains. +// +// Returns whether `activeAddress` changed, so the caller can broadcast it. +function removeWalletFromState(state, walletIdx) { + const wallet = state.wallets[walletIdx]; + const addresses = (wallet.addresses || []).map((a) => a.address); + const previousActive = state.activeAddress; + const activeWasDeleted = + previousActive !== null && + previousActive !== undefined && + addresses.some( + (a) => a.toLowerCase() === String(previousActive).toLowerCase(), + ); + + state.wallets.splice(walletIdx, 1); + + for (const addr of addresses) { + delete state.allowedSites[addr]; + delete state.deniedSites[addr]; + } + + state.hasWallet = state.wallets.length > 0; + + const fallbackAddress = state.hasWallet + ? state.wallets[0].addresses[0]?.address || null + : null; + + if (!state.hasWallet) { + state.selectedWallet = null; + state.selectedAddress = null; + } else if (state.selectedWallet === walletIdx) { + state.selectedWallet = 0; + state.selectedAddress = 0; + } else if ( + typeof state.selectedWallet === "number" && + state.selectedWallet > walletIdx + ) { + state.selectedWallet -= 1; + } + + if (activeWasDeleted || !state.hasWallet) { + state.activeAddress = fallbackAddress; + } + + return { activeAddressChanged: state.activeAddress !== previousActive }; +} + +// Tell the background the active address changed, so it re-emits +// accountsChanged to connected sites. Same call shape as the address +// switch in the home view. +function broadcastActiveChanged() { + const runtime = + typeof browser !== "undefined" ? browser.runtime : chrome.runtime; + runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" }); +} + +module.exports = { removeWalletFromState, broadcastActiveChanged }; diff --git a/tests/walletDelete.test.js b/tests/walletDelete.test.js new file mode 100644 index 0000000..dc592d6 --- /dev/null +++ b/tests/walletDelete.test.js @@ -0,0 +1,129 @@ +const { + removeWalletFromState, + broadcastActiveChanged, +} = require("../src/shared/walletDelete"); + +// Fixed addresses — never used for anything but these tests. +const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; +const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + +function wallet(name, addresses) { + return { + name, + addresses: addresses.map((address) => ({ address })), + }; +} + +// A three-wallet state; wallet A is an HD wallet with two addresses. +function makeState(overrides = {}) { + return { + hasWallet: true, + wallets: [wallet("A", [A0, A1]), wallet("B", [B0]), wallet("C", [C0])], + selectedWallet: 0, + selectedAddress: 0, + activeAddress: A0, + allowedSites: { + [A0]: ["a.example"], + [A1]: ["b.example"], + [B0]: ["c.example"], + }, + deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] }, + ...overrides, + }; +} + +describe("removeWalletFromState", () => { + test("deleting the last wallet clears hasWallet", () => { + const state = makeState({ + wallets: [wallet("A", [A0])], + allowedSites: { [A0]: ["a.example"] }, + deniedSites: {}, + }); + + const { activeAddressChanged } = removeWalletFromState(state, 0); + + expect(state.hasWallet).toBe(false); + expect(state.wallets).toEqual([]); + expect(state.selectedWallet).toBeNull(); + expect(state.selectedAddress).toBeNull(); + expect(state.activeAddress).toBeNull(); + expect(activeAddressChanged).toBe(true); + }); + + test("deleting a non-selected wallet leaves the selection intact", () => { + const state = makeState({ + selectedWallet: 2, + selectedAddress: 0, + activeAddress: C0, + }); + + const { activeAddressChanged } = removeWalletFromState(state, 1); + + // Wallet C moved from index 2 to index 1 by the splice. + expect(state.wallets.map((w) => w.name)).toEqual(["A", "C"]); + expect(state.selectedWallet).toBe(1); + expect(state.selectedAddress).toBe(0); + expect(state.activeAddress).toBe(C0); + expect(activeAddressChanged).toBe(false); + expect(state.hasWallet).toBe(true); + }); + + test("deleting a wallet after the selection does not shift it", () => { + const state = makeState({ + selectedWallet: 1, + selectedAddress: 0, + activeAddress: B0, + }); + + const { activeAddressChanged } = removeWalletFromState(state, 2); + + expect(state.selectedWallet).toBe(1); + expect(state.activeAddress).toBe(B0); + expect(activeAddressChanged).toBe(false); + }); + + test("deleting the active wallet falls back to the first remaining address", () => { + const state = makeState({ + selectedWallet: 0, + selectedAddress: 1, + activeAddress: A1, + }); + + const { activeAddressChanged } = removeWalletFromState(state, 0); + + expect(state.wallets.map((w) => w.name)).toEqual(["B", "C"]); + expect(state.selectedWallet).toBe(0); + expect(state.selectedAddress).toBe(0); + expect(state.activeAddress).toBe(B0); + expect(activeAddressChanged).toBe(true); + expect(state.hasWallet).toBe(true); + }); + + test("site permissions are dropped for every address of the wallet", () => { + const state = makeState(); + + removeWalletFromState(state, 0); + + expect(state.allowedSites).toEqual({ [B0]: ["c.example"] }); + expect(state.deniedSites).toEqual({ [C0]: ["e.example"] }); + }); +}); + +describe("broadcastActiveChanged", () => { + afterEach(() => { + delete global.chrome; + }); + + test("sends AUTISTMASK_ACTIVE_CHANGED to the background", () => { + const sendMessage = jest.fn(); + global.chrome = { runtime: { sendMessage } }; + + broadcastActiveChanged(); + + expect(sendMessage).toHaveBeenCalledWith({ + type: "AUTISTMASK_ACTIVE_CHANGED", + }); + }); +}); -- 2.49.1 From 19cb1ca1b07350c46f631df76ecc55a95595f3a9 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:25:57 +0200 Subject: [PATCH 07/44] docs: correct docs/README.md external services and remove competitor names (closes #163) --- TODO.md | 3 + docs/README.md | 239 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 168 insertions(+), 74 deletions(-) diff --git a/TODO.md b/TODO.md index 78c8ceb..c210509 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,9 @@ undefined identifiers, which is how # Completed Steps +- 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: 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 diff --git a/docs/README.md b/docs/README.md index 3476a73..7244b8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else. ## Why AutistMask Exists -MetaMask has become bloated with swap UIs, portfolio dashboards, analytics, -tracking, and advertisements. It is no longer a simple wallet. Most alternatives -(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users -without a usable option. +The most popular browser-based EVM wallet has become bloated with swap UIs, +portfolio dashboards, analytics, tracking, and advertisements. It is no longer a +simple wallet. The common alternatives only support Chromium browsers, leaving +Firefox users without a usable option. AutistMask exists because a wallet should be a wallet. You should be able to see your balances, send tokens, receive tokens, and connect to sites. That is all a @@ -27,9 +27,10 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. - **Encrypt your recovery phrase and private keys at rest.** Your secrets are encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305 - authenticated encryption (via libsodium). Your password is required only when - signing a transaction. Viewing balances and addresses never requires a - password. + authenticated encryption (via libsodium). Your password is required whenever a + secret has to be decrypted: signing a transaction, signing a message or typed + data, exporting a private key, and deleting a wallet. Viewing balances and + addresses never requires a password. - **Let you choose your own RPC endpoint.** The default is a public Ethereum RPC, but you can point it at your own node or any provider you trust. No @@ -56,23 +57,25 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. - **No NFT galleries or portfolio views.** This is a wallet, not a dashboard. -- **No token auto-discovery.** AutistMask does not scan the blockchain for - tokens you might hold. You add tokens manually by contract address. This - prevents scam tokens from appearing in your wallet uninvited. +- **No third-party token list APIs.** Token balances come from the same block + explorer you configure for transaction history, and the extension ships its + own hardcoded list of top ERC-20 contract addresses for symbol-spoofing + detection. Any token you want tracked across all your addresses, you add + yourself by contract address. -- **No phishing blocklists from third parties.** AutistMask does not phone home - to check URLs against a remote blocklist. It does maintain a local list of - known scam addresses, but this is shipped with the extension, not fetched from - a server. +- **No backend servers operated by the developer.** Nothing is sent to any + server run by AutistMask. Every network destination is listed below. ## How It Works AutistMask is a browser extension that runs entirely in your browser. It does -not have a backend server. It communicates with three external services: +not have a backend server. It communicates with five external destinations: +three you configure yourself, and two fixed ones used for scam detection. ### External Services -**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`) +**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`; +`ethereum-sepolia-rpc.publicnode.com` on Sepolia) This is how AutistMask talks to the Ethereum network. Every wallet needs an Ethereum node to check balances, estimate gas, broadcast transactions, and @@ -80,27 +83,73 @@ verify confirmations. The default is a free public RPC endpoint. You can change this in Settings to any Ethereum JSON-RPC endpoint, including your own local node. -What gets sent: standard Ethereum JSON-RPC requests (balance queries, -transaction broadcasts, gas estimates, ENS lookups). Your addresses are -necessarily visible to the RPC provider when querying balances. +When it is contacted: on every balance refresh (every 10 seconds while the popup +is open, every 60 seconds in the background), when you type an ENS name into the +Send screen, when a send is prepared and broadcast, while a pending transaction +is polled for its receipt, and for the reverse ENS lookups used to label +addresses (cached for 12 hours). -**Blockscout API** (default: `eth.blockscout.com/api/v2`) +What gets sent: standard Ethereum JSON-RPC requests (balance queries, +transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your +addresses are necessarily visible to the RPC provider when querying balances. + +**Blockscout API** (default: `eth.blockscout.com/api/v2`; +`eth-sepolia.blockscout.com/api/v2` on Sepolia) Used to fetch token balances and transaction history. Blockscout is an open-source blockchain explorer. AutistMask queries it for your ERC-20 token -balances and recent transactions. You can change this in Settings to a +balances (including the holder counts used for spam filtering) and your recent +transactions and token transfers. You can change this in Settings to a self-hosted Blockscout instance. +When it is contacted: on every balance refresh, and whenever a screen showing +transaction history is opened. + What gets sent: your Ethereum addresses (to look up balances and transactions). **CoinDesk CADLI price API** (`data-api.coindesk.com`) -Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached -for 5 minutes. No API key is required. No user data is sent -- only a list of -token symbols (e.g. "ETH", "USDC") to get their prices. +Used to fetch current USD prices for ETH and the top 25 tokens. Prices are +cached for 5 minutes. No API key is required. This endpoint is not +user-configurable, and it is not contacted at all while you are on a testnet, +where no USD values are shown. -What gets sent: token symbol names. No addresses, no balances, no identifying -information. +When it is contacted: while the popup is open, at most once every 5 minutes. + +What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no +balances, no identifying information. As with any request, CoinDesk sees your IP +address. + +**Phishing domain blocklist** (`raw.githubusercontent.com`) + +A community-maintained list of phishing domains, used to warn you when a site +that asks to connect, or to have a transaction or signature approved, is a known +scam. A copy is bundled into the extension at build time, so the protection +works before any network request happens. At runtime the extension fetches the +live list to pick up newly added domains, keeping only the entries not already +in the bundled copy (persisted locally if under 256 KiB). This endpoint is not +user-configurable. + +When it is contacted: once when the background script starts, and every 24 hours +after that. It is a plain download of a public file — nothing about you is sent, +but the host sees your IP address. If the fetch fails, the bundled copy is still +used. + +**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) + +When you review a send, AutistMask fetches the recipient's public Etherscan +address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam +warning, and shows a red warning if it finds one. This is a plain page fetch +with no API key, made by your browser. It is best-effort: if it fails, it is +silently ignored. This endpoint is not user-configurable. + +When it is contacted: each time you reach the send confirmation screen. + +What gets sent: the recipient address you are about to send to, and your IP +address. Your own addresses are not sent. + +Etherscan links shown elsewhere in the UI (on addresses, transactions, and token +contracts) are ordinary links. They contact nothing until you click them. ### What Stays Local @@ -123,8 +172,11 @@ word recovery phrase can restore your wallet on any device without your password. The password only protects the copy stored in this browser. If you lose your recovery phrase, your password cannot help you recover it. -Your password is only requested when you send a transaction. Viewing balances, -receiving funds, and browsing transaction history never require your password. +Your password is requested whenever an encrypted secret must be decrypted: when +you send a transaction, when a site asks you to sign a message or typed data, +when you export an address's private key, and when you delete a wallet. Viewing +balances, receiving funds, and browsing transaction history never require your +password. ## Installation @@ -147,34 +199,46 @@ receiving funds, and browsing transaction history never require your password. ### Creating a New Wallet 1. Click the AutistMask icon in your browser toolbar. -2. Click "Add wallet". -3. Click the die button to generate a random 12-word recovery phrase. +2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet". +3. On the "From Phrase" tab, click the die button to generate a random 12-word + recovery phrase. 4. **Write down the recovery phrase and store it safely.** Anyone with these words can take your funds. If you lose them, your wallet is gone. AutistMask cannot recover them for you. -5. Choose a password. This encrypts your recovery phrase on this device. -6. Click "Add". +5. Choose a password and confirm it. This encrypts your recovery phrase on this + device. +6. Click "Import". ### Importing an Existing Wallet -**From a recovery phrase:** Follow the same steps as creating a wallet, but -paste your existing 12 or 24 word recovery phrase instead of generating a new -one. AutistMask uses the same derivation path as MetaMask (`m/44'/60'/0'/0`), so -your addresses will match. +The Add Wallet screen has three tabs: -**From a private key:** On the Add Wallet screen, click "Have a private key -instead?" and paste your private key. This creates a single-address wallet. +**From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of +generating a new one. AutistMask uses the standard BIP-44 Ethereum derivation +path (`m/44'/60'/0'/0`), which is what other wallets use by default, so your +addresses will match and your phrase stays portable in both directions. + +**From Key:** Paste a single private key. This creates a single-address wallet. + +**From xprv:** Paste an extended private key. This imports the HD wallet and +scans for used addresses. + +All three tabs ask for the same password fields, and the "Import" button +finishes the job. ### Adding More Addresses -HD wallets (created from a recovery phrase) can derive multiple addresses. On -the home screen, click the "+" button next to a wallet name to add the next -address. These are deterministic -- the same recovery phrase will always produce -the same sequence of addresses. +HD wallets (created from a recovery phrase or an xprv) can derive multiple +addresses. On the home screen, click the "+" button next to a wallet name to add +the next address. These are deterministic -- the same recovery phrase will +always produce the same sequence of addresses. ### Adding ERC-20 Tokens -AutistMask does not auto-discover tokens. To track a token: +Tokens you hold show up automatically only if they are in the extension's +bundled list of well-known tokens or have at least 1,000 holders; everything +else is treated as spam and hidden. To track a token explicitly (which also +shows it at zero balance), add it by contract address: 1. Go to an address detail view (click `[info]` on any address). 2. Click "+ Token". @@ -183,12 +247,13 @@ AutistMask does not auto-discover tokens. To track a token: 4. Click "Add". The token balance will appear on the address detail screen and on the home -screen. +screen. Tokens can also be added from Settings, under "Tracked Tokens". ## Sending 1. Click "Send" from the home screen or an address detail view. -2. Select what to send (ETH or any tracked ERC-20 token). +2. Select what to send (ETH, or any ERC-20 token with a balance on this address + that survives the spam filters). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). 4. Enter the amount. 5. Click "Review" to see the confirmation screen. @@ -201,11 +266,14 @@ The confirmation screen shows: - **Amount** with USD estimate - **Your current balance** with USD estimate - **Estimated network fee** in ETH with USD estimate +- **Warnings** if the recipient is a contract, a burn address, one of your own + addresses, on the bundled scam-address list, or labelled as a phisher on + Etherscan -After reviewing, click "Send" and enter your password. The transaction will be -broadcast to the network and you will see a waiting screen with a timer. Once -confirmed (or after 60 seconds), you will see either a success or error screen -with the transaction hash and an Etherscan link. +After reviewing, enter your password and click "Sign & Send". The transaction +will be broadcast to the network and you will see a waiting screen with a timer. +Once confirmed (or after 60 seconds), you will see either a success or error +screen with the transaction hash and an Etherscan link. ### Sending a Specific Token @@ -219,10 +287,10 @@ cannot accidentally switch to a different one. 1. Click "Receive" from the home screen or an address detail view. 2. Share the QR code or copy the address using the "Copy address" button. -When receiving ERC-20 tokens, make sure the sender is sending on the Ethereum -network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks -(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be -permanently lost. +When receiving ERC-20 tokens, make sure the sender is sending on the network you +are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens +sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will +not appear and may be permanently lost. ## Connecting to Web3 Sites @@ -237,7 +305,12 @@ pages. When a site requests access to your wallet: When a connected site requests a transaction, a separate approval popup appears showing the transaction details (from, to, value, data). You must enter your -password and click "Confirm" to authorize it. +password and click "Confirm" to authorize it. Message and typed-data signature +requests work the same way, with a "Sign" button, and also require your +password. + +If the requesting site's domain is on the phishing blocklist, all three approval +screens show a red phishing warning before you decide. You can manage site permissions in Settings. Allowed and denied sites can be individually removed to reset their permissions. @@ -247,15 +320,16 @@ individually removed to reset their permissions. AutistMask includes several defenses against common Ethereum scams, all enabled by default: -**Known token symbol verification.** AutistMask ships a list of ~250 legitimate -ERC-20 tokens with their contract addresses. If a transaction claims to involve -a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract, -it is identified as a spoof and hidden. +**Known token symbol verification.** AutistMask ships a list of roughly 500 +legitimate ERC-20 tokens with their contract addresses. If a transaction or +balance claims to involve a known symbol (like "ETH" or "USDT") but comes from +an unrecognized contract, it is identified as a spoof and hidden. **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden -from transaction history and the send token list. Legitimate tokens have -substantial holder counts; scam tokens deployed for address poisoning typically -have zero. +from transaction history and the send token list, and are left out of your +balances unless they are on the bundled known-token list or you added them +yourself. Legitimate tokens have substantial holder counts; scam tokens deployed +for address poisoning typically have zero. **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it adds the contract address to a local blocklist. Future transactions from that @@ -266,31 +340,47 @@ ETH by default) are hidden. Scammers send dust from look-alike addresses to plant them in your transaction history. The threshold is configurable in Settings. -All of these filters can be individually disabled in Settings if you prefer to +**Scam address list.** A list of known fraud, drainer, and phishing addresses is +shipped with the extension. Sending to one of them raises a warning on the +confirmation screen. It contains only addresses involved in fraud -- it is not a +sanctions list. + +**Phishing domain warnings.** Sites asking to connect or to have something +approved are checked against the phishing domain blocklist described under +External Services, and flagged with a red banner if they match. + +The first four filters can be individually disabled in Settings if you prefer to see everything unfiltered. ## Settings Click the gear icon on the home screen to access settings: -- **Wallets**: Add a new wallet. -- **Display**: Toggle whether tracked tokens with zero balance are shown. +- **Wallets**: Your wallets, and "+ Add wallet". +- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add + token". +- **Display**: Toggle whether tracked tokens with zero balance are shown, and + choose the theme (System, Light, or Dark). +- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching + resets the RPC and Blockscout endpoints to that network's defaults. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. You can use your own node for maximum privacy. - **Blockscout API**: Change the Blockscout instance used for token balances and transaction history. You can use a self-hosted instance. -- **Token Spam Protection**: Toggle individual scam filters and set the dust - transaction threshold. +- **Token Spam Protection**: Toggle individual scam filters, set the dust + transaction threshold, and switch timestamps to UTC. - **Allowed Sites / Denied Sites**: View and manage web3 site permissions. +- **About**: License, author, version, release date, and a link to the commit + this build came from. ## Frequently Asked Questions -**Is AutistMask compatible with MetaMask?** +**Can I use AutistMask alongside another wallet?** -Yes. AutistMask uses the same derivation path (`m/44'/60'/0'/0`) as MetaMask. If -you import the same recovery phrase, you will get the same addresses. You can -use both wallets side by side, though only one can be the active -`window.ethereum` provider at a time. +Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing +the same recovery phrase gives you the same addresses as any other wallet using +that path. Two wallet extensions can be installed side by side, though only one +can be the active `window.ethereum` provider at a time. **Can I use AutistMask with a hardware wallet?** @@ -298,8 +388,9 @@ Not yet. Hardware wallet support may be added in the future. **Does AutistMask support networks other than Ethereum mainnet?** -Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be -added in the future. +Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other +networks are supported today. On Sepolia, USD values are not shown, because +testnet tokens have no market value. **Where is my data stored?** @@ -312,7 +403,7 @@ to any server operated by AutistMask. Your data is deleted. Make sure you have your recovery phrase backed up before uninstalling. With your recovery phrase, you can restore your wallet in -AutistMask or any other compatible wallet (MetaMask, etc.) at any time. +AutistMask or any other wallet that uses the standard derivation path. **What happens if a transaction times out?** -- 2.49.1 From b9bc226ae1bb1bf9d87324df633c0dc7a8a2e040 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:31:45 +0200 Subject: [PATCH 08/44] docs: rebuild the README Screen Map from the code (closes #164) --- README.md | 404 ++++++++++++++++++++++++++++++++++++++---------------- TODO.md | 3 + 2 files changed, 288 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 7c10480..7a049bd 100644 --- a/README.md +++ b/README.md @@ -271,10 +271,10 @@ on a different table knows exactly tf I am talking about. Every interactive element must visually indicate that it is clickable. Buttons use a visible border, padding, and a hover state (invert to white-on-black). -Text that triggers an action (e.g. "Import private key") uses an underline. No -invisible hit targets, no bare text that happens to have a click handler. If it -does something when you click it, it must look like it does something when you -click it. +Text that triggers an action (e.g. "Add additional wallet...") uses an +underline. No invisible hit targets, no bare text that happens to have a click +handler. If it does something when you click it, it must look like it does +something when you click it. #### Display Consistency @@ -334,12 +334,18 @@ attack. The core hierarchy is **Wallets → Addresses**: -- A **wallet** is either: - - An **HD wallet** (recovery phrase): generates multiple addresses from a - single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user - can add more addresses with a "+" button. - - A **key wallet** (private key): a single address imported directly from a - private key. No "+" button since there is only one address. +- A **wallet** is one of three types: + - An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple + addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44 + derivation. The user can add more addresses with a "+" button. + - A **key wallet** (`type: "key"`, private key): a single address imported + directly from a private key. No "+" button since there is only one + address. + - An **xprv wallet** (`type: "xprv"`, extended private key): the same + multi-address behavior as an HD wallet, including the "+" button and the + 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. - The user can have multiple wallets, each with multiple addresses (HD) or a single address (key). @@ -354,95 +360,140 @@ menus. ### Screen Map -Navigation uses a stack model (like iOS): each action pushes a screen onto the -stack, and "Back" pops it. The root screen is either Welcome (no wallets) or -Home (has wallets). Screens are listed below with their elements and -transitions. +Navigation uses a stack model (like iOS): each forward action pushes the current +screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and +`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome +(no wallets) or Home (has wallets). Each screen below gives its view id in +parentheses; the registry of view ids is the `VIEWS` array in +`src/popup/views/helpers.js`, and the markup for a screen is the element with id +`view-` plus that view id in `src/popup/index.html`. -#### Welcome +Three elements sit outside the screens and are present on all of them: the title +bar ("AutistMask by @sneak" plus the Settings gear), the flash message line +under it, and the red banner at the very top that appears on a debug build, when +runtime debug mode is on, or when the active network is a testnet. They are not +repeated in the element lists below. -- **When**: No wallets exist yet. -- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button. +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. + +#### Welcome (`welcome`) + +- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root + screen in that case. +- **Elements**: + - "Welcome! To get started, add a wallet." text + - "Add wallet" button - **Transitions**: - "Add wallet" → **AddWallet** -#### Home +#### Home (`main`) - **When**: At least one wallet exists. This is the root screen. - **Elements**: - - Header: "AutistMask", Settings gear button - - Active address ETH balance (large) + USD value (inline parentheses) - - Total USD value across all tokens (small text) + - Active address ETH balance (large) + USD value in parentheses + - "Total:" USD value across ETH and all tracked tokens of the active address - Active address (color dot, full address, etherscan link, tap to copy) - - Send / Receive quick-action buttons + - Send / Receive quick-action buttons, both acting on the active address - ETH/USD price display - - Wallet list: each wallet shows name (tap to rename), "+" button (HD only), - and its addresses with color dots, balances, and `[info]` buttons - - Recent transactions across all addresses (merged, deduplicated, filtered) + - Wallet list: each wallet shows its name (tap to rename inline) and a "+" + 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 + - "Recent Transactions": up to 25 transactions merged across every address + of every wallet, deduplicated by hash and filtered - "Add additional wallet..." link at bottom - **Transitions**: - - Tap address row → sets active address (no screen change) + - Tap address row → sets the active address and broadcasts + `AUTISTMASK_ACTIVE_CHANGED` (no screen change) + - Tap wallet name → inline rename field (no screen change) + - "+" on wallet → derives the next address inline (no screen change) - `[info]` on address → **AddressDetail** - - "Send" → **Send** (selects active address) + - "Send" → **Send** (refuses with a flash message on a zero balance) - "Receive" → **Receive** (shows active address QR) - - "+" on wallet → derives next address inline + - Tap home tx row → **TransactionDetail** - "Add additional wallet..." → **AddWallet** - Settings gear → **Settings** (toggles; tap again to return) - - Tap home tx row → **AddressDetail** (for the address involved) -#### AddWallet +#### AddWallet (`add-wallet`) -- **When**: User wants to add a new wallet (from Home, Welcome, or Settings). +- **When**: User wants to add a new wallet (from Welcome, Home, or Settings). + This one screen covers all three import modes; there is no separate import + screen. - **Elements**: - - "Add Wallet" heading, "Back" button - - Instruction text - - Die button `[die]` (generates random recovery phrase) - - Recovery phrase textarea - - Backup warning box (shown after die is clicked) - - Password + confirm password inputs - - "Add" button - - "Have a private key instead?" link -- **Transitions**: - - "Add" (valid phrase + password) → **Home** - - "Back" → previous screen (Home or Welcome) - - "Have a private key instead?" → **ImportKey** - -#### ImportKey - -- **When**: User wants to import a single private key. -- **Elements**: - - "Import Private Key" heading, "Back" button - - Instruction text - - Private key input (password-masked) - - Password + confirm password inputs + - "Back" button, "Add Wallet" heading + - Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`), + "From xprv" (`tab-xprv`) — each showing its own form section: + - **From Phrase**: instruction text, a die button that generates a + random recovery phrase, a recovery phrase textarea, and a backup + warning box that becomes visible once the die button has been used + - **From Key**: instruction text and a masked private key input + - **From xprv**: instruction text and a masked extended private key + input + - Password + confirm password inputs, with a hint line whose wording depends + on the selected tab - "Import" button - **Transitions**: - - "Import" (valid key + password) → **Home** - - "Back" → **AddWallet** + - "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. + - "Import" with an invalid entry, a duplicate wallet or address, or a short + or mismatched password → flash message, no screen change + - "Back" → previous screen (Welcome, Home, or Settings) -#### AddressDetail +#### AddressDetail (`address`) - **When**: User tapped `[info]` on an address from Home. - **Elements**: - "Back" button - Blockie identicon (48px, centered) - Title: "Wallet Name — Address N" - - ENS name (if resolved, bold with color dot) + - 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** - - Send / Receive / + Token buttons + - Send / Receive / + Token buttons and a "···" menu button + - "···" dropdown containing a single "Export Private Key" entry - Transaction list (with ENS resolution for counterparties) - **Transitions**: - Tap balance row → **AddressToken** (for that token) - - "Send" → **Send** + - "Send" → **Send** (refuses with a flash message on a zero balance) - "Receive" → **Receive** - "+ Token" → **AddToken** + - "···" → "Export Private Key" → **ExportPrivKey** - Tap transaction row → **TransactionDetail** - - "Back" → **Home** + - "Back" → previous screen (Home) -#### AddressToken +#### ExportPrivKey (`export-privkey`) + +- **When**: User chose "Export Private Key" from the "···" menu on + AddressDetail. This screen discloses secret material. +- **Elements**: + - "Back" button + - Blockie identicon (48px, centered) + - "Export Private Key" heading + - "Wallet Name — Address N" and the full address (etherscan link, tap to + copy) + - Warning that anyone holding the private key can transfer all funds from + the address + - Error line + - Password input and "Reveal" button, shown until the key is revealed + - The private key on a highlighted background, tap to copy, shown only after + the password has been accepted +- **Transitions**: + - "Reveal" (correct password) → decrypts the wallet secret, derives this + address's key, hides the password input and shows the key (no screen + change) + - "Reveal" (wrong password) → "Wrong password." on the error line, nothing + revealed + - "Back" → clears the key and password from the DOM, then → previous screen + (AddressDetail) + +#### AddressToken (`address-token`) - **When**: User clicked a specific token balance on AddressDetail. - **Elements**: @@ -453,49 +504,64 @@ transitions. - USD total for this token - Single token balance line (4 decimal places) - Send / Receive buttons + - Token contract well (ERC-20 only): full contract address (tap to copy, + etherscan link) plus name, symbol, decimals, holder count and project + website where known - Token-filtered transaction list (only this token's transfers) - **Transitions**: - - "Send" → **Send** (token pre-selected and locked in dropdown) + - "Send" → **Send** (token locked: the dropdown is replaced by a static + symbol and contract address) - "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens) - Tap transaction row → **TransactionDetail** - - "Back" → **AddressDetail** + - "Back" → previous screen (AddressDetail) -#### Send +#### Send (`send`) -- **When**: User wants to send ETH or a token from this address. +- **When**: User wants to send ETH or a token, from Home, AddressDetail, or + AddressToken. - **Elements**: - - "Send" heading, "Back" button + - "Back" button, "Send" heading - From: address with color dot + etherscan link - What to send: token dropdown (or static display with contract address when locked from AddressToken) - - To: address or ENS name input + - To: address or ENS name input, with an inline validation message - Amount input with current balance display - - "Review" button + - "Review" button, disabled until the recipient validates - **Transitions**: - "Review" (valid inputs, ENS resolved) → **ConfirmTx** - - "Back" → **AddressToken** (if came from token view) or **AddressDetail** + - "Review" with an unresolvable ENS name or an invalid amount → flash + message, no screen change + - "Back" → previous screen (Home, AddressDetail, or AddressToken) -#### ConfirmTx +#### ConfirmTx (`confirm-tx`) - **When**: User reviewed send details and is ready to authorize. - **Elements**: - - "Confirm Transaction" heading, "Back" button + - "Back" button, "Confirm Transaction" heading - Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)" - Token contract: full address + etherscan link (ERC-20 only) - From: blockie + color dot + full address + etherscan link + wallet title - To: blockie + color dot + full address + etherscan link + ENS name - Amount: value + symbol (USD in parentheses) - Your balance: value + symbol (USD in parentheses) - - Estimated network fee: ETH amount (USD in parentheses), fetched async - - Warnings (scam address, self-send) + - Estimated network fee: "Estimating..." then the ETH amount (USD in + parentheses) or "Unable to estimate", fetched async + - Warnings: inline warnings from the local checks (scam address, self-send) + plus four reserved warning boxes made visible by the async checks — + recipient with no transaction history, recipient is a contract, burn + address, and an Etherscan phishing/scam label - Errors (insufficient balance) - - "Send" button (disabled if errors) + - Password: an inline field on this screen, not a modal, with its own error + line + - "Sign & Send" button (disabled if errors) - **Transitions**: - - "Send" → password modal → broadcast tx → **WaitTx** - - "Send" → password modal → broadcast fails → **ErrorTx** + - "Sign & Send" (correct password) → broadcast tx → **WaitTx** + - "Sign & Send" (correct password) → broadcast fails → **ErrorTx** + - "Sign & Send" (wrong password) → "Wrong password." on the password error + line, no screen change - "Back" → **Send** -#### WaitTx +#### WaitTx (`wait-tx`) - **When**: Transaction has been broadcast, waiting for on-chain confirmation. - **Elements**: @@ -509,20 +575,24 @@ transitions. - Receipt found → **SuccessTx** - 60 seconds without confirmation → **ErrorTx** (timeout message) -#### SuccessTx +#### SuccessTx (`success-tx`) - **When**: Transaction confirmed on-chain. - **Elements**: - "Transaction Confirmed" heading + - Decoded action well (shown when the transaction carried recognized + calldata; the top-level Amount and To are hidden in that case) - Amount + symbol - To: color dot + full address + etherscan link - Block number - Transaction hash: full hash (tap to copy) + etherscan link - "Done" button - **Transitions**: - - "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Done" in the approval popup → closes the popup window + - "Done" otherwise → resets the navigation stack, then → **AddressToken** + (if `selectedToken` set) or **AddressDetail** -#### ErrorTx +#### ErrorTx (`error-tx`) - **When**: Transaction broadcast failed, or timed out waiting for confirmation. - **Elements**: @@ -534,24 +604,28 @@ transitions. full hash (tap to copy) + etherscan link - "Done" button - **Transitions**: - - "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Done" in the approval popup → closes the popup window + - "Done" otherwise → resets the navigation stack, then → **AddressToken** + (if `selectedToken` set) or **AddressDetail** -#### Receive +#### Receive (`receive`) -- **When**: User wants to receive funds at this address. +- **When**: User wants to receive funds at this address, from Home, + AddressDetail, or AddressToken. - **Elements**: - - "Receive" heading, "Back" button + - "Back" button, "Receive" heading - Instruction text - QR code encoding the address - Full address (color dot, selectable, etherscan link) - "Copy address" button - ERC-20 warning (shown when navigating from AddressToken for non-ETH token) - **Transitions**: - - "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Back" → previous screen (Home, AddressDetail, or AddressToken) -#### TransactionDetail +#### TransactionDetail (`transaction`) -- **When**: User tapped a transaction row from AddressDetail or AddressToken. +- **When**: User tapped a transaction row on Home, AddressDetail, or + AddressToken. - **Elements** (grouped into logical blocks using light well containers; field labels are self-explanatory so groups have no headings): - "Transaction" heading, "Back" button @@ -576,91 +650,182 @@ transitions. - Raw data (shown when calldata is present): full calldata in monospace dashed border - **Transitions**: - - "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Back" → previous screen (Home, AddressDetail, or AddressToken) -#### AddToken +#### AddToken (`add-token`) -- **When**: User wants to track an ERC-20 token on this address. +- **When**: User wants to track an ERC-20 token, reached from "+ Token" on + AddressDetail. - **Elements**: - - "Add Token" heading, "Back" button + - "Back" button, "Add Token" heading - Instruction text (find contract address on Etherscan) - Contract address input - - Token info preview (name, symbol — fetched from contract) - - Common token quick-pick buttons + - 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 - "Add" button - **Transitions**: - - "Add" (valid contract) → **AddressDetail** - - "Back" → **AddressDetail** + - "Add" (valid contract) → tracks the token, pops the stack, and re-renders + **AddressDetail** + - "Add" with a token already tracked, a scam-listed address, or a failed + contract lookup → flash message, no screen change + - "Back" → previous screen (AddressDetail) -#### Settings +#### Settings (`settings`) -- **When**: User tapped Settings gear from Home. +- **When**: User tapped the Settings gear. - **Elements**: - - "Settings" heading, "Back" button - - Wallets: "+ Add wallet" button - - Display: "Show tracked tokens with zero balance" checkbox - - Ethereum RPC: endpoint URL input + "Save" button - - Blockscout API: endpoint URL input + "Save" button + - "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 + - 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 + selector (System / Light / Dark) + - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching + resets the RPC and Blockscout endpoints to that network's defaults + - Ethereum RPC: endpoint URL input + "Save" button (validated against + `eth_chainId` before being saved) + - Blockscout API: endpoint URL input + "Save" button (validated against + `/stats` before being saved) - Token Spam Protection: - "Hide tokens with fewer than 1,000 holders" checkbox - "Hide transactions from detected fraud contracts" checkbox - "Hide dust transactions below N gwei" checkbox + threshold input + - "UTC Timestamps" checkbox - Allowed Sites: list with remove buttons - Denied Sites: list with remove buttons + - About: project link, license, author, version, release date, and the + commit, which links to the commit in the repository + - Debug: hidden until revealed, then an "Enable debug mode" checkbox that + turns on the red banner and verbose logging - **Transitions**: - "+ Add wallet" → **AddWallet** - - "Back" (or Settings gear again) → **Home** + - "+ Add token" → **SettingsAddToken** + - `[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 + change) + - Ten clicks on the version → reveals the Debug well (no screen change) + - "Back" (or Settings gear again) → previous screen (Home) -#### SiteApproval +#### DeleteWallet (`delete-wallet-confirm`) -- **When**: A website requests wallet access via `eth_requestAccounts`. Opened - in a separate popup by the background script. +- **When**: User tapped the `[x]` next to a wallet in Settings. +- **Elements**: + - "Back" button, "Delete Wallet" heading + - Warning naming the wallet and stating that deletion is permanent and any + funds are unrecoverable without the recovery phrase + - Error line + - Password input + - "Confirm Delete" button +- **Transitions**: + - "Confirm Delete" (correct password, other wallets remain) → deletes the + wallet and its site permissions, then → **Settings** with a "Wallet + deleted." flash message + - "Confirm Delete" (correct password, last wallet) → deletes the wallet, + clears the selection and the navigation stack, then → **Welcome** + - Either way, the active address moves only if it belonged to the deleted + wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does + (`src/shared/walletDelete.js`) + - "Confirm Delete" (wrong password) → "Wrong password." on the error line, + nothing deleted + - "Back" → previous screen (Settings) + +#### 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. +- **Elements**: + - "Back" button, "Add Token" heading + - Instruction text + - "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked + tokens are disabled) + - "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 +- **Transitions**: + - Any of the three add paths, 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 + - "Back" → previous screen (Settings) + +#### SiteApproval (`approve-site`) + +- **When**: A website requests wallet access via `eth_requestAccounts` or + `wallet_requestPermissions` and is on neither the allowed nor the denied list. + The background script prefers the toolbar popup (`action.openPopup()`) and + falls back to a separate popup window (`src/background/index.js`, + `requestApproval()`). - **Elements**: - "Connection Request" heading - - Site hostname (bold) + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) + - Site hostname (bold) + "wants to connect to your wallet" - Address that will be shared (color dot + full address + etherscan link) - "Remember my choice for this site" checkbox - "Allow" / "Deny" buttons - **Transitions**: - - "Allow" / "Deny" → closes popup (returns result to background script) + - "Allow" / "Deny" → closes popup (returns result to background script; the + choice is persisted to the allowed or denied list when "Remember" is + checked) + - Popup closed without answering → treated as a denial -#### TxApproval +#### TxApproval (`approve-tx`) - **When**: A connected website requests a transaction via - `eth_sendTransaction`. Opened via the toolbar popup by the background script. + `eth_sendTransaction`. Always opened in a separate popup window by the + background script (`windows.create()`), because the request is triggered + programmatically rather than by a user gesture. - **Elements**: - "Transaction Request" heading + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) - Site hostname (bold) + "wants to send a transaction" - Decoded action (if calldata is recognized): action name, token details, amounts, steps, deadline (see Transaction Decoding) - From: color dot + full address + etherscan link - - To/Contract: color dot + full address + etherscan link (or "contract + - Contract: color dot + full address + etherscan link (or "contract creation"), token symbol label if known - - Value: amount in ETH (4 decimal places) + - Value: amount in ETH (4 decimal places, USD in parentheses) - Raw data: full calldata displayed inline (shown if present) - - Password input + - Password input and an error line - "Confirm" / "Reject" buttons - **Transitions**: - - "Confirm" (with password) → closes popup (returns result to background) + - "Confirm" (correct password) → decrypts and signs in the popup, hands the + signed transaction to the background to broadcast, then → **WaitTx** in + the same popup window + - "Confirm" (wrong password) → error line, no screen change - "Reject" → closes popup (returns rejection to background) + - Popup window closed without answering → the request is rejected with + EIP-1193 code 4001 -#### SignApproval +#### SignApproval (`approve-sign`) - **When**: A connected website requests a message signature via - `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar - popup by the background script. + `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as + TxApproval, in a separate popup window. - **Elements**: - "Signature Request" heading + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) - Site hostname (bold) + "wants you to sign a message" + - Danger warning box (shown for `eth_sign`, which signs a raw hash) - Type: "Personal message" or "Typed data (EIP-712)" - From: color dot + full address + etherscan link - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/ message fields (EIP-712 typed data) - - Password input + - Password input and an error line - "Sign" / "Reject" buttons - **Transitions**: - - "Sign" (with password) → signs locally → closes popup (returns signature) + - "Sign" (correct password) → signs locally → closes popup (returns + signature) + - "Sign" (wrong password, or a signing failure) → error line, no screen + change - "Reject" → closes popup (returns rejection to background) + - Popup window closed without answering → the request is rejected with + EIP-1193 code 4001 ### External Services @@ -815,6 +980,7 @@ hardcoded test phrase. - Create new HD wallet (generates 12-word recovery phrase) - Import HD wallet from existing 12 or 24 word recovery phrase - Import single-address wallet from private key +- Import multi-address wallet from an extended private key (`xprv`) - Add multiple addresses within an HD wallet - Manage multiple wallets simultaneously - View ETH balance per address @@ -986,7 +1152,7 @@ Currently supported: ### Transactions -- [ ] Gas estimation and fee display before confirming +- [x] Gas estimation and fee display before confirming ### Testing diff --git a/TODO.md b/TODO.md index c210509..608523b 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,9 @@ undefined identifiers, which is how # Completed Steps +- 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)). -- 2.49.1 From cf5f582be9d4b2dc4d4bcf5662fe5193fb54e72f Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:41:16 +0200 Subject: [PATCH 09/44] docs: correct three README claims contradicted by the code (closes #213) --- README.md | 55 ++++++++++++++++++++++++++++++++++++++----------------- TODO.md | 3 +++ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7a049bd..b8df696 100644 --- a/README.md +++ b/README.md @@ -346,17 +346,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 @@ -393,7 +408,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 +416,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 +469,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) @@ -861,7 +876,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 +999,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 +1146,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 @@ -1187,14 +1204,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 diff --git a/TODO.md b/TODO.md index 608523b..d202884 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,9 @@ undefined identifiers, which is how # Completed Steps +- 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)). -- 2.49.1 From 9b957ffd69eb45834d48b66e0c502409303a6b4e Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:51:22 +0200 Subject: [PATCH 10/44] fix: derive hasWallet from the wallet list on load (closes #195) --- TODO.md | 4 ++ src/shared/state.js | 5 ++- tests/state.test.js | 104 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/state.test.js diff --git a/TODO.md b/TODO.md index d202884..7882ec1 100644 --- a/TODO.md +++ b/TODO.md @@ -53,6 +53,10 @@ undefined identifiers, which is how - 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 diff --git a/src/shared/state.js b/src/shared/state.js index b0192d8..14e76eb 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -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; diff --git a/tests/state.test.js b/tests/state.test.js new file mode 100644 index 0000000..353d7e6 --- /dev/null +++ b/tests/state.test.js @@ -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); + }); +}); -- 2.49.1 From 93e3f6e4e2afaf2e072b985fded42fd97a35fad4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:55:06 +0200 Subject: [PATCH 11/44] fix: correct verify-build diagnostics and close two robustness gaps (closes #180) --- TODO.md | 6 +++ build.js | 6 +++ script/verify-build | 96 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 7882ec1..b6edf7d 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,12 @@ undefined identifiers, which is how # Completed Steps +- 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)). diff --git a/build.js b/build.js index 0ecdad4..6a9fe31 100644 --- a/build.js +++ b/build.js @@ -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]) => { diff --git a/script/verify-build b/script/verify-build index 23b1b7c..6e6bf75 100755 --- a/script/verify-build +++ b/script/verify-build @@ -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." -- 2.49.1 From f271bcd7b419ff4bca47c0e2208f65d32b694931 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:56:30 +0200 Subject: [PATCH 12/44] fix: one transaction history row per value movement (closes #177) --- TODO.md | 4 + src/shared/transactions.js | 135 +++++++++----- tests/transactions.test.js | 362 +++++++++++++++++++++++++++++++++++-- 3 files changed, 440 insertions(+), 61 deletions(-) diff --git a/TODO.md b/TODO.md index b6edf7d..bfa9501 100644 --- a/TODO.md +++ b/TODO.md @@ -67,6 +67,10 @@ undefined identifiers, which is how 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)). diff --git a/src/shared/transactions.js b/src/shared/transactions.js index 5f528eb..303579f 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -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, +}; diff --git a/tests/transactions.test.js b/tests/transactions.test.js index 73bf7b7..cc68f2f 100644 --- a/tests/transactions.test.js +++ b/tests/transactions.test.js @@ -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 () => { -- 2.49.1 From 86cdea5e4e4798dc60484a97b8131a3b71501bf0 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 14:57:51 +0200 Subject: [PATCH 13/44] =?UTF-8?q?chore:=20repo=20policy=20compliance=20swe?= =?UTF-8?q?ep=20=E2=80=94=20test=20rerun,=20frozen=20lockfile,=20documente?= =?UTF-8?q?d=20targets=20(closes=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 +++ Makefile | 2 +- README.md | 18 +++++++++++++++++- TODO.md | 4 ++++ build.js | 11 ++++++++++- package.json | 1 + script/test | 8 +++++++- 7 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index da592f8..12efda1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/Makefile b/Makefile index cae3066..453d9e3 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ setup: @script/setup install: - @yarn install + @yarn install --frozen-lockfile test: @script/test diff --git a/README.md b/README.md index b8df696..da2d16e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index bfa9501..680d63b 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,10 @@ undefined identifiers, which is how # Completed Steps +- 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 diff --git a/build.js b/build.js index 6a9fe31..c5b3953 100644 --- a/build.js +++ b/build.js @@ -121,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" }, ); diff --git a/package.json b/package.json index c7ecbdd..45a6c80 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/script/test b/script/test index 498ed0d..ed0dee4 100755 --- a/script/test +++ b/script/test @@ -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 "$@" -- 2.49.1 From f455b0ae7f822490c1c3a8eedf91325d4edc334e Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 15:06:35 +0200 Subject: [PATCH 14/44] test: known-answer coverage for HD derivation and the vault (closes #159) --- README.md | 4 +- TODO.md | 3 + tests/vault.test.js | 346 +++++++++++++++++++++++++++++++++++++++++++ tests/wallet.test.js | 318 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 tests/vault.test.js diff --git a/README.md b/README.md index da2d16e..956769b 100644 --- a/README.md +++ b/README.md @@ -1189,8 +1189,8 @@ Currently supported: ### Testing -- [ ] Tests for mnemonic generation and address derivation -- [ ] Tests for xpub derivation and child address generation +- [x] Tests for mnemonic generation and address derivation +- [x] Tests for xpub derivation and child address generation - [ ] Test on Firefox (Manifest V2) ### Scam List diff --git a/TODO.md b/TODO.md index 680d63b..08910e2 100644 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,9 @@ undefined identifiers, which is how 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: Known-answer test coverage for the crypto core — BIP-39/BIP-32 + derivation in `wallet.js` and the Argon2id vault in `vault.js` + ([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)). - 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)). diff --git a/tests/vault.test.js b/tests/vault.test.js new file mode 100644 index 0000000..a81a6d8 --- /dev/null +++ b/tests/vault.test.js @@ -0,0 +1,346 @@ +// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption +// that protects recovery phrases and private keys at rest. +// +// The properties that matter here are the ones whose failure is silent. A +// vault that decrypts under the wrong password, that hands back plaintext from +// a ciphertext an attacker edited, that reuses a nonce, or that leaves the +// recovery phrase readable somewhere in the stored blob all look exactly like +// a working vault from the UI. So each test below asserts a negative: the +// thing that must not happen. +// +// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production +// interactive parameters, which the module hardcodes. The parameters are not +// weakened or overridden anywhere in this file — they are pinned by the "key +// derivation cost" tests, since they are the vault's only defence against an +// offline attack on a stolen blob. The suite is kept inside script/test's +// 30-second budget by sharing one encrypted fixture across the tamper cases +// instead of re-encrypting per test. + +const sodium = require("libsodium-wrappers-sumo"); +const { + encryptWithPassword, + decryptWithPassword, +} = require("../src/shared/vault"); + +// A publicly known development phrase. Never fund it. +const SECRET = "test test test test test test test test test test test junk"; +const PASSWORD = "correct horse battery staple"; +const WRONG_PASSWORD = "correct horse battery stapl"; + +const SALT_BYTES = 16; +const NONCE_BYTES = 24; +const POLY1305_TAG_BYTES = 16; + +const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/; + +function b64decode(s) { + return sodium.from_base64(s); +} + +// A shallow copy with one field replaced, so the shared fixture is never +// mutated by a tamper test. +function withField(blob, field, value) { + return { ...blob, [field]: value }; +} + +// Flip the low bit of one byte of a base64-encoded field. +function flipByte(b64, index) { + const bytes = b64decode(b64); + bytes[index] ^= 0x01; + return sodium.to_base64(bytes); +} + +let vault; + +beforeAll(async () => { + await sodium.ready; + vault = await encryptWithPassword(SECRET, PASSWORD); +}); + +describe("stored blob shape", () => { + test("is exactly the documented { salt, nonce, ciphertext }", () => { + expect(Object.keys(vault).sort()).toEqual([ + "ciphertext", + "nonce", + "salt", + ]); + }); + + test("every field is a base64 string", () => { + for (const field of ["salt", "nonce", "ciphertext"]) { + expect(typeof vault[field]).toBe("string"); + expect(vault[field]).toMatch(BASE64); + } + }); + + test("salt and nonce are full length", () => { + expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES); + expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES); + }); + + test("ciphertext carries a Poly1305 authentication tag", () => { + expect(b64decode(vault.ciphertext)).toHaveLength( + SECRET.length + POLY1305_TAG_BYTES, + ); + }); + + test("the blob survives JSON storage unchanged", async () => { + const stored = JSON.parse(JSON.stringify(vault)); + + await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe( + SECRET, + ); + }); +}); + +describe("no plaintext leakage", () => { + test("the secret does not appear in the serialized vault", () => { + const serialized = JSON.stringify(vault); + + expect(serialized).not.toContain(SECRET); + for (const word of new Set(SECRET.split(" "))) { + expect(serialized).not.toContain(word); + } + }); + + test("the ciphertext bytes do not contain the secret bytes", () => { + const bytes = Buffer.from(b64decode(vault.ciphertext)); + + expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false); + // Not even the first word, which would betray an unencrypted prefix. + expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false); + }); + + test("the password does not appear in the serialized vault", () => { + expect(JSON.stringify(vault)).not.toContain(PASSWORD); + }); +}); + +describe("round trip", () => { + test("decrypts back to the original secret", async () => { + await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe( + SECRET, + ); + }); + + test("survives a non-ASCII plaintext byte for byte", async () => { + const unicode = "recovery phrase é中文\u{1f600}"; + + const blob = await encryptWithPassword(unicode, PASSWORD); + + await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe( + unicode, + ); + }); + + test("an empty password still round-trips and is not a bypass", async () => { + const blob = await encryptWithPassword(SECRET, ""); + + await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET); + // An empty password must not act as a skeleton key on other vaults, + // nor may a real password open an empty-password vault. + await expect(decryptWithPassword(vault, "")).rejects.toThrow(); + await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow(); + }); +}); + +describe("fresh salt and nonce", () => { + test("two encryptions of the same plaintext differ in all three fields", async () => { + const second = await encryptWithPassword(SECRET, PASSWORD); + + expect(second.salt).not.toBe(vault.salt); + expect(second.nonce).not.toBe(vault.nonce); + expect(second.ciphertext).not.toBe(vault.ciphertext); + await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe( + SECRET, + ); + }); +}); + +describe("key derivation cost", () => { + // Argon2id's opslimit and memlimit are the whole of the vault's resistance + // to an offline attack on a stolen blob, and lowering them breaks nothing + // any other test here can see — the suite merely runs faster. So pin them + // directly, both to libsodium's INTERACTIVE constants and to the absolute + // values those constants must keep meaning. + const INTERACTIVE_OPSLIMIT = 2; + const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024; + + test("the interactive constants still mean 2 passes over 64 MiB", () => { + expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe( + INTERACTIVE_OPSLIMIT, + ); + expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe( + INTERACTIVE_MEMLIMIT, + ); + // The floor these must never quietly be swapped for: _MIN is one pass + // over 8 KiB, an 8192x reduction in memory cost. + expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan( + INTERACTIVE_OPSLIMIT, + ); + expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan( + INTERACTIVE_MEMLIMIT, + ); + }); + + test("a key derived at the interactive parameters opens the vault", () => { + // Independent of any spy, and of the module's own code path: derive + // the key here from the vault's published salt at the interactive cost + // and open its ciphertext directly. A vault whose key came from any + // other opslimit, memlimit or Argon2id variant yields a different key + // and cannot be opened this way. + const key = sodium.crypto_pwhash( + sodium.crypto_secretbox_KEYBYTES, + PASSWORD, + b64decode(vault.salt), + INTERACTIVE_OPSLIMIT, + INTERACTIVE_MEMLIMIT, + sodium.crypto_pwhash_ALG_ARGON2ID13, + ); + const opened = sodium.crypto_secretbox_open_easy( + b64decode(vault.ciphertext), + b64decode(vault.nonce), + key, + ); + + expect(sodium.to_string(opened)).toBe(SECRET); + }); + + test.each([ + [ + "encrypt", + async () => { + await encryptWithPassword(SECRET, PASSWORD); + }, + ], + [ + "decrypt", + async () => { + await decryptWithPassword(vault, PASSWORD); + }, + ], + ])("%s derives exactly one key at the interactive cost", async (_, run) => { + const spy = jest.spyOn(sodium, "crypto_pwhash"); + try { + await run(); + + expect(spy).toHaveBeenCalledTimes(1); + const [keyBytes, , salt, opslimit, memlimit, alg] = + spy.mock.calls[0]; + expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES); + expect(salt).toHaveLength(SALT_BYTES); + expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE); + expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE); + expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("wrong password", () => { + test("is rejected, and rejects cleanly", async () => { + // rejects.toThrow asserts a rejected promise, not a synchronous throw + // and not an unhandled rejection: the caller can catch this. + await expect( + decryptWithPassword(vault, WRONG_PASSWORD), + ).rejects.toThrow(); + }); + + test("returns no plaintext, not even partially", async () => { + const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch( + (err) => err, + ); + + expect(result).toBeInstanceOf(Error); + expect(String(result)).not.toContain("test"); + }); + + test("the empty password is rejected on a password-protected vault", async () => { + await expect(decryptWithPassword(vault, "")).rejects.toThrow(); + }); +}); + +describe("tampering", () => { + test("a flipped ciphertext bit is rejected by the auth tag", async () => { + const tampered = withField( + vault, + "ciphertext", + flipByte(vault.ciphertext, 0), + ); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a flipped bit in the authentication tag itself is rejected", async () => { + const tagStart = b64decode(vault.ciphertext).length - 1; + const tampered = withField( + vault, + "ciphertext", + flipByte(vault.ciphertext, tagStart), + ); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a flipped nonce bit is rejected", async () => { + const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0)); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a flipped salt bit is rejected", async () => { + const tampered = withField(vault, "salt", flipByte(vault.salt, 0)); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a truncated ciphertext is rejected", async () => { + const bytes = b64decode(vault.ciphertext); + const tampered = withField( + vault, + "ciphertext", + sodium.to_base64(bytes.slice(0, bytes.length - 4)), + ); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a ciphertext shorter than the auth tag is rejected", async () => { + const tampered = withField( + vault, + "ciphertext", + sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)), + ); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a truncated nonce is rejected", async () => { + const tampered = withField( + vault, + "nonce", + sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)), + ); + + await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); + }); + + test("a ciphertext from another vault is rejected", async () => { + const other = await encryptWithPassword("a different secret", PASSWORD); + const spliced = withField(vault, "ciphertext", other.ciphertext); + + await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow(); + }); + + test("a missing field is rejected rather than decrypted", async () => { + for (const field of ["salt", "nonce", "ciphertext"]) { + const broken = { ...vault }; + delete broken[field]; + + await expect( + decryptWithPassword(broken, PASSWORD), + ).rejects.toThrow(); + } + }); +}); diff --git a/tests/wallet.test.js b/tests/wallet.test.js index 620bd19..9054552 100644 --- a/tests/wallet.test.js +++ b/tests/wallet.test.js @@ -1,4 +1,6 @@ -// Tests for the DEBUG build flag as it gates mnemonic generation. +// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic +// generation (first two describes), and HD key derivation against published +// known-answer vectors (rest of the file). // // The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle // time. Under jest the global is absent, which is exactly the release-build @@ -92,3 +94,317 @@ describe("generateMnemonic in a debug build", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Key derivation. +// +// Every address below is a published constant, not something this codebase +// produced. Asserting against what the implementation happens to return today +// would pass just as happily with the wrong coin type, the wrong path depth or +// a non-empty seed passphrase, all of which silently send funds to addresses +// no other wallet can recover. +// +// Vector sources: +// +// VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard +// development recovery phrase and the first three accounts it yields at +// m/44'/60'/0'/0/n with an empty seed passphrase, as published in the +// Hardhat and Ganache documentation. Publicly known; never fund it. +// +// ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy +// phrase (Trezor's official BIP-39 vector set, first entry) and its +// m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second, +// independently published phrase so the pin is not one vector deep. +// +// BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1 +// (seed 000102030405060708090a0b0c0d0e0f). +// +// The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the +// published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD +// path must meet at the same address from two different directions. + +const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers"); +const wallet = require("../src/shared/wallet"); +const { BIP44_ETH_PATH } = require("../src/shared/constants"); + +const VECTOR_PHRASE = + "test test test test test test test test test test test junk"; + +const VECTOR_ADDRESSES = [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", +]; + +const VECTOR_PRIVATE_KEYS = [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", +]; + +const ZERO_ENTROPY_PHRASE = + "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about"; +const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"; + +const BIP32_VECTOR_1_XPRV = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" + + "ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + +// The master (depth-0) extended private key for a phrase, which is what the +// import-an-xprv flow is handed. Built with ethers rather than with the module +// under test, so hdWalletFromXprv is not being checked against itself. +function masterXprv(phrase, passphrase = "") { + return HDNodeWallet.fromSeed( + Mnemonic.fromPhrase(phrase, passphrase).computeSeed(), + ).extendedKey; +} + +describe("hdWalletFromMnemonic", () => { + test("first address matches the published vector for m/44'/60'/0'/0/0", () => { + expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe( + VECTOR_ADDRESSES[0], + ); + }); + + test("second published phrase derives its published address", () => { + expect( + wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress, + ).toBe(ZERO_ENTROPY_ADDRESS); + }); + + test("returns the account-level xpub, which is watch-only", () => { + const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); + + expect(xpub.startsWith("xpub")).toBe(true); + // A neutered ethers node exposes no private key at all, so accept + // either absent or null rather than pinning which. + expect( + HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null, + ).toBeNull(); + expect(wallet.isValidXprv(xpub)).toBe(false); + }); + + test("the account path is the documented BIP-44 Ethereum path", () => { + expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); + }); + + test("rejects an invalid recovery phrase rather than deriving from it", () => { + expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow(); + }); +}); + +describe("deriveAddressFromXpub", () => { + const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); + + test.each([0, 1, 2])( + "child %i matches the published vector address", + (index) => { + expect(wallet.deriveAddressFromXpub(xpub, index)).toBe( + VECTOR_ADDRESSES[index], + ); + }, + ); + + test("agrees with hdWalletFromMnemonic at index 0", () => { + expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe( + wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress, + ); + }); + + test("rejects garbage instead of returning an address", () => { + expect(() => + wallet.deriveAddressFromXpub("xpub-nonsense", 0), + ).toThrow(); + }); +}); + +describe("hdWalletFromMnemonic seed passphrase handling", () => { + // The vectors above are only reproducible with an empty BIP-39 seed + // passphrase. This pins that the empty string reaching + // HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the + // published address is unreachable, and a wallet derived that way could + // not be restored anywhere else from the phrase alone. + test("a non-empty seed passphrase would yield a different address", () => { + const withPassphrase = HDNodeWallet.fromPhrase( + VECTOR_PHRASE, + "TREZOR", + BIP44_ETH_PATH, + ).deriveChild(0).address; + + expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]); + }); +}); + +describe("hdWalletFromXprv", () => { + // hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while + // hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a + // depth-0 master key the two are the same derivation; these tests pin that + // equivalence to a published address rather than assuming it. + test("master xprv for the vector phrase yields the vector address", () => { + expect( + wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress, + ).toBe(VECTOR_ADDRESSES[0]); + }); + + test("agrees with hdWalletFromMnemonic on xpub and address", () => { + const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); + const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)); + + expect(fromXprv).toEqual(fromPhrase); + }); + + test("derived xpub generates the same child addresses", () => { + const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)); + + expect( + [0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)), + ).toEqual(VECTOR_ADDRESSES); + }); + + test("accepts the BIP-32 test vector 1 master key", () => { + const { xpub, firstAddress } = + wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV); + + expect(xpub.startsWith("xpub")).toBe(true); + expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + test("rejects a watch-only xpub", () => { + const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); + + expect(() => wallet.hdWalletFromXprv(xpub)).toThrow(); + }); + + test("rejects garbage", () => { + expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow(); + }); +}); + +describe("isValidXprv", () => { + test.each([ + ["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true], + ["the empty string", "", false], + ["garbage", "not-a-key", false], + ["a bare private key", VECTOR_PRIVATE_KEYS[0], false], + ["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false], + ["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false], + ])("%s -> %s", (_name, key, expected) => { + expect(wallet.isValidXprv(key)).toBe(expected); + }); + + test("a watch-only xpub is not an xprv", () => { + const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); + + expect(wallet.isValidXprv(xpub)).toBe(false); + }); + + // Skipped: this asserts the correct behaviour, which the code does not + // currently have. isValidXprv gates the paste-your-extended-private-key + // import in src/popup/views/addWallet.js:215, and it accepts a key with a + // one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58 + // checksum verification whenever the decoded payload is the usual 82 + // bytes, which is the whole point of that checksum. Measured on this + // vector: changing any one of the last 14 characters passes validation, + // and for 9 of those 14 positions the import silently yields a *different* + // wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of + // 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown. + // Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; out of scope + // here, which is tests only. Unskip when it is fixed. + test.skip("rejects an extended key with a one-character typo", () => { + const index = BIP32_VECTOR_1_XPRV.length - 8; + const typo = + BIP32_VECTOR_1_XPRV.slice(0, index) + + (BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") + + BIP32_VECTOR_1_XPRV.slice(index + 1); + + expect(wallet.isValidXprv(typo)).toBe(false); + }); +}); + +describe("isValidMnemonic", () => { + test.each([ + ["the vector phrase", VECTOR_PHRASE, true], + ["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true], + [ + "a 12-word phrase with a bad checksum", + "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon abandon", + false, + ], + ["an 11-word phrase", "abandon ".repeat(10) + "about", false], + ["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false], + ["the empty string", "", false], + ["garbage", "correct horse battery staple", false], + ])("%s -> %s", (_name, phrase, expected) => { + expect(wallet.isValidMnemonic(phrase)).toBe(expected); + }); +}); + +describe("addressFromPrivateKey", () => { + test.each([0, 1, 2])( + "published key %i yields its published address", + (index) => { + expect( + wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]), + ).toBe(VECTOR_ADDRESSES[index]); + }, + ); + + test("rejects a key of the wrong length", () => { + expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow(); + }); + + test("rejects the empty string", () => { + expect(() => wallet.addressFromPrivateKey("")).toThrow(); + }); +}); + +describe("getSignerForAddress", () => { + test.each([0, 1, 2])("hd wallet, address index %i", (index) => { + const signer = wallet.getSignerForAddress( + { type: "hd" }, + index, + VECTOR_PHRASE, + ); + + expect(signer.address).toBe(VECTOR_ADDRESSES[index]); + expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]); + }); + + test.each([0, 1, 2])("xprv wallet, address index %i", (index) => { + const signer = wallet.getSignerForAddress( + { type: "xprv" }, + index, + masterXprv(VECTOR_PHRASE), + ); + + expect(signer.address).toBe(VECTOR_ADDRESSES[index]); + expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]); + }); + + test("single private key ignores the address index", () => { + for (const index of [0, 1, 2]) { + const signer = wallet.getSignerForAddress( + { type: "privkey" }, + index, + VECTOR_PRIVATE_KEYS[1], + ); + + expect(signer.address).toBe(VECTOR_ADDRESSES[1]); + } + }); + + test("the returned signer signs recoverably as the expected address", async () => { + const signer = wallet.getSignerForAddress( + { type: "hd" }, + 1, + VECTOR_PHRASE, + ); + const message = "AutistMask derivation test"; + + const signature = await signer.signMessage(message); + + expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]); + }); +}); -- 2.49.1 From 12acf4dc8c1309f317c73e8e102764dd5671d1ff Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 15:16:48 +0200 Subject: [PATCH 15/44] fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179) --- README.md | 3 +- TODO.md | 5 ++ src/popup/views/settings.js | 10 +++- src/shared/transactions.js | 51 ++++++++++------- tests/transactions.test.js | 107 ++++++++++++++++++++++++++++++------ 5 files changed, 136 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 956769b..5a2d7a7 100644 --- a/README.md +++ b/README.md @@ -1108,7 +1108,8 @@ indexes it as a real token transfer. it. AutistMask hides transactions below a configurable dust threshold (default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning dust while low enough to preserve any transfer a user would plausibly care - about. The threshold is user-configurable in Settings. + about. The threshold is user-configurable in Settings; a threshold of `0` + hides nothing, exactly as clearing the checkbox does. - **User-configurable**: All of the above filters (known symbol verification, low-holder threshold, fraud contract blocklist, dust threshold) are settings diff --git a/TODO.md b/TODO.md index 08910e2..6e39305 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,11 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of + falling back to the 100,000 gwei default, and every address comparison in + `src/shared/transactions.js` goes through one case-normalising helper so a + checksummed genuine contract is no longer read as a spoof + ([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)). - 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 diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js index 576c251..a527f77 100644 --- a/src/popup/views/settings.js +++ b/src/popup/views/settings.js @@ -304,11 +304,17 @@ function init(ctx) { $("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").addEventListener("change", async () => { - const val = parseInt($("settings-dust-threshold").value, 10); - if (!isNaN(val) && val >= 0) { + const raw = $("settings-dust-threshold").value.trim(); + const val = Number(raw); + // 0 is accepted and means "hide nothing". Empty, negative, + // fractional and non-numeric input is rejected outright rather than + // coerced, and the field is put back to the stored threshold so it + // never shows a value the wallet is not using. + if (raw !== "" && Number.isInteger(val) && val >= 0) { state.dustThresholdGwei = val; await saveState(); } + $("settings-dust-threshold").value = state.dustThresholdGwei; }); $("settings-utc-timestamps").checked = state.utcTimestamps; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index 303579f..9941dbc 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("./log"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); +// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum +// over the address, not part of its identity. Every address comparison in +// this file goes through this helper, so an address arriving in checksummed +// or upper-case form can never be read as a different address. +function normalizeAddress(addr) { + return (addr || "").toLowerCase(); +} + function formatTxValue(val) { const parts = val.split("."); if (parts.length === 1) return val + ".0000"; @@ -30,10 +38,10 @@ function parseTx(tx, addrLower) { let exactValue = formatEther(rawWei); let rawAmount = rawWei; let rawUnit = "wei"; - let direction = from.toLowerCase() === addrLower ? "sent" : "received"; + let direction = normalizeAddress(from) === addrLower ? "sent" : "received"; let directionLabel = direction === "sent" ? "Sent" : "Received"; if (toIsContract && method && method !== "transfer") { - const token = TOKEN_BY_ADDRESS.get(to.toLowerCase()); + const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to)); if (token) { symbol = token.symbol; } @@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) { const to = tt.to?.hash || ""; const decimals = parseInt(tt.total?.decimals || "18", 10); const rawVal = tt.total?.value || "0"; - const direction = from.toLowerCase() === addrLower ? "sent" : "received"; + const direction = + normalizeAddress(from) === addrLower ? "sent" : "received"; const sym = tt.token?.symbol || "?"; return { hash: tt.transaction_hash, @@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) { direction: direction, directionLabel: direction === "sent" ? "Sent" : "Received", isError: false, - contractAddress: ( - tt.token?.address_hash || - tt.token?.address || - "" - ).toLowerCase(), + contractAddress: normalizeAddress( + tt.token?.address_hash || tt.token?.address || "", + ), holders: parseInt(tt.token?.holders_count || "0", 10), }; } @@ -194,7 +201,7 @@ function mergeTransactions(txs, tokenTransfers) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { log.debugf("fetchRecentTransactions", address); - const addrLower = address.toLowerCase(); + const addrLower = normalizeAddress(address); const [txResp, ttResp] = await Promise.all([ debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), @@ -243,34 +250,38 @@ function isSpoofedSymbol(tx) { if (!KNOWN_SYMBOLS.has(symbol)) return false; const legit = KNOWN_SYMBOLS.get(symbol); if (legit === null) return true; // "ETH" as ERC-20 is always fake - return tx.contractAddress !== legit; + return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit); } // Pure filter function. Takes raw transactions and filter settings, // returns { transactions, newFraudContracts }. function filterTransactions(txs, filters = {}) { const fraudSet = new Set( - (filters.fraudContracts || []).map((a) => a.toLowerCase()), + (filters.fraudContracts || []).map(normalizeAddress), ); + // The dust threshold defaults only when it is unset (nullish): a + // threshold of 0 is a real value meaning "hide nothing", since no + // transaction has a value below 0 gwei. It is therefore equivalent to + // clearing the hide-dust checkbox, and the two controls cannot override + // each other in either direction. + const dustThresholdGwei = filters.dustThresholdGwei ?? 100000; const newFraud = []; const filtered = []; for (const tx of txs) { + const contract = normalizeAddress(tx.contractAddress); + // Always filter spoofed known symbols and record the fraud contract if (isSpoofedSymbol(tx)) { - if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { - fraudSet.add(tx.contractAddress); - newFraud.push(tx.contractAddress); + if (contract && !fraudSet.has(contract)) { + fraudSet.add(contract); + newFraud.push(contract); } continue; } // Filter fraud contracts if setting is on - if ( - filters.hideFraudContracts && - tx.contractAddress && - fraudSet.has(tx.contractAddress) - ) { + if (filters.hideFraudContracts && contract && fraudSet.has(contract)) { continue; } @@ -291,7 +302,7 @@ function filterTransactions(txs, filters = {}) { filters.hideDustTransactions && !tx.isContractCall && tx.valueGwei !== null && - tx.valueGwei < (filters.dustThresholdGwei || 100000) + tx.valueGwei < dustThresholdGwei ) { continue; } diff --git a/tests/transactions.test.js b/tests/transactions.test.js index cc68f2f..a9d51d5 100644 --- a/tests/transactions.test.js +++ b/tests/transactions.test.js @@ -329,18 +329,42 @@ describe("known-symbol spoof verification", () => { expect(result.newFraudContracts).toEqual([]); }); - // Documents current behaviour, not desired behaviour: the spoof check - // compares tx.contractAddress against a lowercased known address with - // ===, so a caller passing a checksummed address for a genuine token has - // it treated as a spoof. In the app this cannot happen because - // parseTokenTransfer lowercases, but the exported function is not - // defensive about it the way the blocklist check is. - test("current behaviour: a checksummed genuine contract is treated as a spoof", () => { - const genuineButChecksummed = tokenTx({ + // Regression guard (#179): EIP-55 mixed case is a checksum over the + // address, not part of its identity, so the contract comparison must be + // case-insensitive in both directions — a genuine token in any casing is + // genuine, and a spoof cannot escape detection by changing its casing. + test("a genuine contract in all-lowercase form is not a spoof", () => { + const tx = tokenTx({ contractAddress: USDC_CONTRACT }); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); + + test("a genuine contract in EIP-55 checksummed form is not a spoof", () => { + const tx = tokenTx({ contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", }); - const result = filterTransactions([genuineButChecksummed], filters()); + const result = filterTransactions([tx], filters()); + expect(result.transactions).toEqual([tx]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("a genuine contract in all-uppercase form is not a spoof", () => { + const tx = tokenTx({ + contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(), + }); + const result = filterTransactions([tx], filters()); + expect(result.transactions).toEqual([tx]); + expect(result.newFraudContracts).toEqual([]); + }); + + test("a genuinely different contract claiming USDC is still a spoof in any casing", () => { + const tx = tokenTx({ + contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82", + }); + const result = filterTransactions([tx], filters()); expect(result.transactions).toEqual([]); + // The recorded fraud contract is normalised, so the persisted + // blocklist matches later transfers whatever casing they arrive in. + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); }); // Documents current behaviour: README.md:810-814 says all four filters @@ -412,6 +436,21 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => { expect(tx.holders).toBeNull(); expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); }); + + // Regression guard (#179): an unknown holder count on a real token — the + // explorer rate-limited the call, or a self-hosted instance omits the + // field — must not be read as zero holders. Reading it that way hides a + // legitimate transfer from the user's history, the same over-filtering + // harm as the zero-threshold bug. This pins the `tx.holders !== null` + // guard, which no fixture previously reached. + test("a token whose holder count is unknown is not filtered", () => { + const tx = tokenTx({ + symbol: NOVEL_SPAM_SYMBOL, + contractAddress: NOVEL_SPAM_CONTRACT, + holders: null, + }); + expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); + }); }); describe("fraud contract blocklist", () => { @@ -575,16 +614,50 @@ describe("dust threshold filtering", () => { expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); }); - // Documents current behaviour: the threshold is read as - // `filters.dustThresholdGwei || 100000`, so a user who sets the threshold - // to 0 (the natural way to ask for no dust filtering while leaving the - // toggle on) silently gets the 100,000 gwei default instead. - test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => { - const result = filterTransactions( - [dustOf(50)], + // Regression guard (#179): 0 is a real threshold meaning "hide nothing", + // not an absent one. It used to be swallowed by `|| 100000`, so the one + // value a user would pick to see everything was the one that did not + // work. + test("a threshold of 0 hides nothing, leaving the toggle on", () => { + const dust = dustOf(50); + const zero = dustOf(0); + const opts = filters({ dustThresholdGwei: 0 }); + expect(filterTransactions([dust], opts).transactions).toEqual([dust]); + expect(filterTransactions([zero], opts).transactions).toEqual([zero]); + }); + + test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => { + const tx = nativeDustTransfer(); + const thresholdZero = filterTransactions( + [tx], filters({ dustThresholdGwei: 0 }), ); - expect(result.transactions).toEqual([]); + const toggleOff = filterTransactions( + [tx], + filters({ hideDustTransactions: false }), + ); + expect(thresholdZero.transactions).toEqual([tx]); + expect(toggleOff.transactions).toEqual([tx]); + }); + + test("0, unset and a set threshold are three distinct behaviours", () => { + const tx = dustOf(50); + expect( + filterTransactions([tx], filters({ dustThresholdGwei: 0 })) + .transactions, + ).toEqual([tx]); + expect( + filterTransactions([tx], filters({ dustThresholdGwei: undefined })) + .transactions, + ).toEqual([]); + expect( + filterTransactions([tx], filters({ dustThresholdGwei: 40 })) + .transactions, + ).toEqual([tx]); + expect( + filterTransactions([tx], filters({ dustThresholdGwei: 60 })) + .transactions, + ).toEqual([]); }); }); -- 2.49.1 From 3e5d6323ceebf38fa47ee22d89d8bf8cf1ce6b22 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 15:25:17 +0200 Subject: [PATCH 16/44] feat: password-gated recovery phrase display for HD wallets (closes #161) --- README.md | 50 ++++++- TODO.md | 4 + src/popup/index.html | 46 ++++++ src/popup/index.js | 19 +-- src/popup/restorableViews.js | 29 ++++ src/popup/views/helpers.js | 19 +++ src/popup/views/settings.js | 19 +++ src/popup/views/showPhrase.js | 154 ++++++++++++++++++++ src/shared/wallet.js | 10 ++ tests/e2e/harness.js | 8 ++ tests/e2e/run.js | 264 +++++++++++++++++++++++++++++++++- tests/showPhrase.test.js | 94 ++++++++++++ 12 files changed, 693 insertions(+), 23 deletions(-) create mode 100644 src/popup/restorableViews.js create mode 100644 src/popup/views/showPhrase.js create mode 100644 tests/showPhrase.test.js diff --git a/README.md b/README.md index 5a2d7a7..2559557 100644 --- a/README.md +++ b/README.md @@ -123,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 @@ -406,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`) @@ -707,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 @@ -733,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 @@ -740,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. @@ -1182,7 +1218,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 diff --git a/TODO.md b/TODO.md index 6e39305..1a3b52d 100644 --- a/TODO.md +++ b/TODO.md @@ -49,6 +49,10 @@ undefined identifiers, which is how `src/shared/transactions.js` goes through one case-normalising helper so a checksummed genuine contract is no longer read as a spoof ([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)). +- 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 diff --git a/src/popup/index.html b/src/popup/index.html index a38e901..361e864 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -1098,6 +1098,52 @@ + + + `; }); 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(); diff --git a/src/popup/views/showPhrase.js b/src/popup/views/showPhrase.js new file mode 100644 index 0000000..80ad327 --- /dev/null +++ b/src/popup/views/showPhrase.js @@ -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 }; diff --git a/src/shared/wallet.js b/src/shared/wallet.js index 8b2dadc..66d760a 100644 --- a/src/shared/wallet.js +++ b/src/shared/wallet.js @@ -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, }; diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js index f51fe78..b080d7d 100644 --- a/tests/e2e/harness.js +++ b/tests/e2e/harness.js @@ -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, diff --git a/tests/e2e/run.js b/tests/e2e/run.js index a771e3c..c3b7c46 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -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 diff --git a/tests/showPhrase.test.js b/tests/showPhrase.test.js new file mode 100644 index 0000000..d7cf2dd --- /dev/null +++ b/tests/showPhrase.test.js @@ -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/); + }); +}); -- 2.49.1 From fb9e8f5542df1e9d5384fd50c3e5ce57c1b343ae Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 15:26:43 +0200 Subject: [PATCH 17/44] fix: NUL-delimit verify-build's dist walk so no path escapes the check (closes #223) --- TODO.md | 4 ++ script/verify-build | 109 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index 1a3b52d..8b17986 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,10 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts + `dist/` is a real directory, so a path with a trailing space or a newline can + no longer carry a debug marker past the unlisted-bundle check + ([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)). - 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of falling back to the 100,000 gwei default, and every address comparison in `src/shared/transactions.js` goes through one case-normalising helper so a diff --git a/script/verify-build b/script/verify-build index 6e6bf75..e766efc 100755 --- a/script/verify-build +++ b/script/verify-build @@ -22,6 +22,18 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Absolute path to this script, resolved before anything cd's anywhere. +# check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be +# relative to a directory we are about to leave. +SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" + +# Internal re-entry flag; see scan_dist_paths. +SCAN_FLAG="--scan-dist-paths" + +# A literal newline, for the is_listed guard. +NEWLINE=' +' + MANIFEST="dist/constants-bundles.txt" MARKER_ON="autistmask-build-debug=on" MARKER_OFF="autistmask-build-debug=off" @@ -29,11 +41,20 @@ MARKER_OFF="autistmask-build-debug=off" # Set by read_marker. MARKER="" +# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT +# trap because fail() exits from wherever it is called. +LISTING="" + fail() { echo "verify-build: FAIL: $*" >&2 exit 1 } +cleanup() { + [ -z "$LISTING" ] || rm -f "$LISTING" +} +trap cleanup EXIT + # 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 @@ -58,7 +79,17 @@ has_marker() { # 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. +# +# A path containing a newline is answered without asking grep, because grep +# would read the pattern as two patterns and report a match on either. That is +# how such a path escaped this check even once the walk stopped splitting it: +# the half before the newline matched a listed line and the file was skipped. +# The manifest is line-delimited, so it cannot name such a path at all, and +# "not listed" is the only true answer. is_listed() { + case "$1" in + *"$NEWLINE"*) return 1 ;; + esac _il_status=0 grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$? case "$_il_status" in @@ -117,35 +148,66 @@ read_marker() { # 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. +# avoid. Every regular file and every symlink under dist/ is searched — that +# is the whole of what a build emits — 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: +# That claim only holds if the walk is exhaustive and every name survives it +# intact, so four things are enforced here rather than assumed: # +# - the walk is NUL-delimited and the paths reach the check as arguments, so +# no name can be reshaped on the way in. Read line by line, a name with a +# trailing space lost it to read's field splitting and the remnant then +# matched a manifest line, and a name containing a newline arrived as a +# listed path plus an empty one. Both left a marker-carrying, unlisted file +# unchecked while the script still reported success. Delivering such a name +# intact is only half of it; is_listed also has to keep it out of grep's +# pattern, for the same reason. # - 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. +# has_marker exists to prevent. The status cannot be read off a pipeline, +# so the listing lands in a file that xargs then reads back. # - 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. +# - dist/ itself must be a directory and not a symlink, which main asserts +# before anything reads through it. find does not follow a symlink named on +# its own command line, so a linked dist/ collapses this walk to one entry +# and cross-checks nothing. +# +# Types other than regular files and symlinks are left out on purpose: a build +# emits none of them, and grep on a fifo would hang rather than fail. check_unlisted_bundles() { + LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || + fail "could not create a temporary file for the dist/ listing, so the + tree was never walked. Refusing to report success." + _find_status=0 - _listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$? + find dist \( -type f -o -type l \) -print0 >"$LISTING" || _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 + _scan_status=0 + xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$? + [ "$_scan_status" -eq 0 ] || + fail "the unlisted-bundle scan exited $_scan_status: either a path + under dist/ failed the check reported above, or the scan could not be run + at all. Refusing to report success." +} + +# The per-path half of check_unlisted_bundles. It runs in a re-invocation of +# this script, so it uses the same is_listed and has_marker as the rest of the +# file rather than a second copy of them that could drift. Paths arrive as +# arguments and are never split, joined or trimmed. +scan_dist_paths() { + for _file in "$@"; do if is_listed "$_file"; then continue fi @@ -154,9 +216,7 @@ check_unlisted_bundles() { fail "$_file carries a debug marker but is absent from $MANIFEST, so the manifest no longer describes the emitted bundles." fi - done < Date: Tue, 11 Aug 2026 15:31:50 +0200 Subject: [PATCH 18/44] fix: enforce the base58 checksum and reject non-master extended keys (closes #210) --- TODO.md | 4 + src/popup/index.html | 4 +- src/popup/views/addWallet.js | 16 +++- src/shared/wallet.js | 86 +++++++++++++++---- tests/wallet.test.js | 158 ++++++++++++++++++++++++++++++++--- 5 files changed, 238 insertions(+), 30 deletions(-) diff --git a/TODO.md b/TODO.md index 8b17986..985e5b6 100644 --- a/TODO.md +++ b/TODO.md @@ -57,6 +57,10 @@ undefined identifiers, which is how 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: Extended-key import hardened — the base58 checksum is now enforced + on every xprv and xpub, and a non-master key is refused with an explanation + instead of being derived beneath + ([#210](https://git.eeqj.de/sneak/AutistMask/issues/210)). - 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 diff --git a/src/popup/index.html b/src/popup/index.html index 361e864..b40e098 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -136,7 +136,9 @@