Compare commits

...

22 Commits

Author SHA1 Message Date
clawbot
0e3dd14001 fix: correct verify-build diagnostics and close its vacuous passes (closes #180)
Some checks failed
check / check (push) Has been cancelled
The both-markers diagnostic claimed the debug branch was still live. It is
not: with the __BUILD_DEBUG__ define removed, the emitted bundle carries
`typeof __BUILD_DEBUG__<"u"?__BUILD_DEBUG__:!1`, and in extension context the
identifier is undeclared, so DEBUG evaluates to false at runtime. The message
now states what the check does prove -- DEBUG was not resolved at build time,
so the release/debug distinction is no longer enforced and which way the
unresolved fallback evaluates is an accident a refactor can flip -- and it
remains a hard failure. The other seven failure messages were reviewed and
none needed rewording.

has_marker no longer swallows grep's exit 2 with 2>/dev/null. Match and
no-match are answers about the emitted output; an unreadable file is not, and
is now reported as a permissions or I/O fault instead of as "the emitted
output changed shape". Both paths still fail hard. The manifest-membership
grep gets the same treatment in is_listed: an unreadable manifest is no longer
answered as "this file is not listed".

The unlisted-bundle scan no longer filters by extension, so the endsWith(".js")
test in build.js is the only place that assumption lives. A bundle emitted
under another extension previously escaped the manifest and the cross-check at
once; it now fails as unlisted. Both sites carry a comment naming the other.

That makes the scan the sole guard on build.js's filter, so its walk has to be
exhaustive rather than assumed to be. find's exit status was discarded twice
over -- the pipeline reported sort's status, and set -e does not fire on an
assignment from a successful pipeline -- so a subtree find could not descend
printed to stderr and was then silently omitted, and an unlisted marker-carrying
bundle inside a chmod 000 directory passed green. The status is now captured
and a non-zero find is a hard failure naming the unwalked tree; the sort moved
off the status-bearing pipeline. Symlinks are walked as well: a marker-carrying
bundle reachable under an unlisted path is a stale manifest whether the path is
a link or a file, and a link that cannot be read through fails closed via the
exit-2 path.

Also: the manifest must be readable and a listed bundle must be non-empty,
so a vacuous input fails loudly rather than reaching a marker check that
cannot prove anything.
2026-08-11 12:38:44 +00:00
b9bc226ae1 docs: rebuild the README Screen Map from the code (closes #164)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:31:45 +02:00
19cb1ca1b0 docs: correct docs/README.md external services and remove competitor names (closes #163)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:25:57 +02:00
b882cede9f fix: repair wallet state on delete (closes #156)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:23:06 +02:00
d93eda31a0 docs: rewrite TODO.md workflow for the branch-per-issue model on next (closes #191)
All checks were successful
check / check (push) Successful in 22s
2026-08-11 14:15:05 +02:00
e9fa8bec47 build: assert DEBUG is off in every emitted bundle as a post-build check (closes #170)
All checks were successful
check / check (push) Successful in 18s
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.
2026-08-10 16:15:22 +02:00
ad9162d057 security: decrypt and sign dApp approvals in the popup (closes #157)
All checks were successful
check / check (push) Successful in 24s
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.
2026-08-10 15:59:30 +02:00
188882d635 test: cover the address-poisoning filters in transactions.js (closes #160)
Some checks failed
check / check (push) Has been cancelled
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.
2026-08-10 15:53:15 +02:00
e8ad8325c8 test: containerized Chrome end-to-end harness that drives the real popup (closes #181)
Some checks failed
check / check (push) Has been cancelled
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
2026-08-10 15:49:32 +02:00
f7f141a757 security: make DEBUG a build-time flag defaulting to off (closes #149) (#169)
Some checks failed
check / check (push) Has been cancelled
Fixes the highest-severity item in the repo: `src/shared/constants.js` had
`const DEBUG = true;`, so `generateMnemonic()` returned the publicly committed
`DEBUG_MNEMONIC` for every wallet created from a build of `main`, and the real
entropy path was dead code in every artifact we could produce.

## What changed

**`build.js`** — `AUTISTMASK_DEBUG` is read from the environment and injected
as a `__BUILD_DEBUG__` entry in the existing esbuild `define` map, next to the
other `__BUILD_*__` defines. Only the exact value `1` enables it; unset, empty,
`true`, or a typo all yield a release build, so the insecure direction requires
a deliberate opt-in and any mistake fails safe. The build prints
`Build mode: release (DEBUG off)` or
`Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)`.

**`src/shared/constants.js`** — `DEBUG` now uses the same `typeof` guard that
`src/shared/buildInfo.js` already uses for the other build-time defines, and
defaults to `false` when the define is absent (jest, plain `require`).
`DEBUG_MNEMONIC` stays in the tree and stays exported.

**`Makefile`** — new `build-debug` target (`AUTISTMASK_DEBUG=1` + the same
build) so a debug build stays a one-liner for development.

**`README.md`** — new "Debug Builds" subsection under Getting Started, and the
DEBUG Mode Policy section now states that `DEBUG` is build-time-only and spells
out the boundary against the runtime toggle.

**`tests/wallet.test.js`** — new, covering both build modes.

**`TODO.md`** — refreshed in the same commit (details at the bottom).

No new `if (DEBUG)` branch was added and nothing about what DEBUG *does* changed:
still exactly the red banner plus the hardcoded test phrase, per the README
DEBUG Mode Policy and `RULES.md:76-80`.

## The interaction with the #145 settings toggle

This is the subtle part, so spelling out the reasoning.

There are two distinct debug flags in the tree after #145:

1. the compile-time `DEBUG` constant from `constants.js`, and
2. the runtime `debugMode` state flag, which the settings easter egg toggles
   and which `settings.js:379` pushes into `log.js` via `setRuntimeDebug()`.

`log.js` merges them: `isDebug()` is `DEBUG || _runtimeDebug`. That merged
value feeds exactly two things — the log level threshold (`log.js:24`) and the
red banner (`views/helpers.js:71`). Making the banner user-toggleable is the
intended behavior of #145, and this PR leaves it alone.

`generateMnemonic()` does **not** consult `isDebug()`. It reads the
compile-time `DEBUG` binding directly. That distinction is what makes a release
build coherent: with `__BUILD_DEBUG__` false, `DEBUG` is false in the bundle,
so no amount of clicking the version ten times and flipping the toggle can
reach `return DEBUG_MNEMONIC`. The user can turn the banner and verbose logging
on in a release build; they cannot turn the hardcoded phrase on.

The failure mode to guard against is someone later "tidying up" the two flags by
routing `wallet.js` through `isDebug()`, which would silently reintroduce this
exact vulnerability with the runtime toggle as the trigger. Three things now
guard that: a comment at the `wallet.js` call site saying it must stay the
compile-time constant and why, the same statement in the README DEBUG Mode
Policy, and a regression test that calls `setRuntimeDebug(true)`, asserts
`isDebug()` is genuinely true, and then asserts `generateMnemonic()` still
returns fresh entropy.

I considered instead making the runtime toggle unavailable in release builds,
but rejected it: that removes a feature #145 deliberately added, and it defends
the wrong boundary. The banner is not the dangerous part; the mnemonic path is,
and that one is already unreachable.

## Verification

`make check` — green, 5 suites, 55 tests, plus lint and fmt-check. It also ran
via the pre-commit hook on the commit itself.

The new tests, per the verification standard in the manager comment on the
issue (not just `a !== b`) — with the flag off: two successive
`generateMnemonic()` calls differ, both pass `isValidMnemonic`, both are 12
words, neither equals `DEBUG_MNEMONIC`, and the result derives a usable HD
wallet (`xpub` + a well-formed first address), so a broken implementation
returning a counter or a truncated phrase would fail. Same assertions again
with the runtime toggle forced on. With the flag on (`__BUILD_DEBUG__` defined
before a `jest.resetModules()` re-require): `DEBUG` is `true` and
`generateMnemonic()` returns `DEBUG_MNEMONIC`, so the debug path is proven
working rather than silently deleted.

Build artifacts — `make build` and `make build-debug` both produce `dist/chrome`
and `dist/firefox` successfully. Grepping the minified bundles for the emitted
`DEBUG` export value across all four bundles (chrome popup, chrome background,
firefox popup, firefox background):

    # after make build
    $ grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -c
          4 DEBUG:!1

    # after make build-debug
    $ grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -c
          4 DEBUG:!0

`!1` is minified `false`, `!0` is `true`. Also checked the fail-safe path:
`AUTISTMASK_DEBUG=true make build` prints `Build mode: release (DEBUG off)` and
likewise yields `4 DEBUG:!1`.

One thing a reviewer should know about the grep: the `DEBUG_MNEMONIC` string
literal is still present in the release bundle. That is not a leak of anything
(the phrase is in this public repo already) and it does not mean the branch is
live — esbuild cannot tree-shake a CommonJS `module.exports` object, so the
constant survives while `DEBUG` folds to `false`. The compiled function is
`function PL(){return ML?UL:f_.fromEntropy(globalThis.crypto.getRandomValues(new Uint8Array(16))).phrase}`
where `ML` is the `DEBUG:!1` export. So "the phrase string is absent" is *not*
the right test for a release build; "the exported `DEBUG` is `!1`" is, which is
what I checked.

## `TODO.md` refresh

Per the manager comment: Status rewritten (no branch in flight —
`feat/issue-144-settings-about` landed as #145, scripts-to-rule-them-all landed
as #148, so the `scripts/` question is resolved; `make check` recorded as
verified green on `main` at `23aeae4`); the completed "Verify main passes make
check" Future Step removed; Future Steps rewritten against the #149-#168
backlog in rough priority order, keeping branch pruning (now #167) and the
pre-1.0 security review (noting #149 and #157 are parts of it but it is
broader).

One deliberate deviation to flag rather than bury: the manager asked that Next
Step become this issue. Taken literally against the Workflow section, this
commit *completes* #149, which would normally move it into Completed Steps. I
followed the repo's existing convention for in-flight work instead — the
previous Next Step was phrased as "Land feat/issue-144-settings-about", so Next
Step is now "Land #149 ... PR open, awaiting review", which is accurate until
this merges. Whoever merges should move it to Completed Steps and promote the
first Future Step. Happy to change it if the reviewer prefers the strict
reading.

## Out of scope

`script/lint` being `prettier --check` only and unable to catch undefined
identifiers (#152) — noted in the TODO but not fixed here; I greped for `DEBUG`
consumers by hand rather than relying on lint, as advised. Nothing else in the
DEBUG consumer set (`log.js`, `helpers.js`, `state.js`, `settings.js`) changed
behavior.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #169
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-09 16:19:09 +02:00
23aeae4841 feat: add About well to settings with build info and debug easter egg (#145)
All checks were successful
check / check (push) Successful in 22s
## Summary

Add a new well at the bottom of the settings view displaying application info (license, author, version, build date, git commit hash linked to Gitea), and an easter egg that reveals a debug mode toggle after clicking the version number 10 times.

## Changes

- **`src/popup/index.html`**: Added About well with license, author, version, build date, and linked commit hash. Added hidden debug well with debug mode toggle.
- **`src/popup/views/settings.js`**: Populate About well from build-time constants. Implement version click counter (10 clicks reveals debug well). Wire up debug mode toggle to state and runtime logger.
- **`src/shared/buildInfo.js`** (new): Module exporting build-time constants (`BUILD_VERSION`, `BUILD_LICENSE`, `BUILD_AUTHOR`, `BUILD_COMMIT`, `BUILD_DATE`, `GITEA_COMMIT_URL`) injected by esbuild define.
- **`build.js`**: Read git commit hash (short + full) and version from package.json, pass as esbuild `define` constants. Also reads `GIT_COMMIT_SHORT`/`GIT_COMMIT_FULL` env vars for Docker builds.
- **`Dockerfile`**: Accept `GIT_COMMIT_SHORT` and `GIT_COMMIT_FULL` build args, set as env vars for the build step.
- **`Makefile`**: Pass git commit hashes as Docker build args.
- **`src/shared/state.js`**: Add `debugMode` boolean to state (persisted).
- **`src/shared/log.js`**: Add runtime debug flag that supplements the compile-time `DEBUG` constant. Debug mode toggle updates this flag immediately.

closes #144

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@sneak.cloud>
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #145
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-07-26 18:39:09 +02:00
d046a24115 scripts-to-rule-them-all (#148)
All checks were successful
check / check (push) Successful in 5s
Reviewed-on: #148
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:14:26 +02:00
3ffc0bec80 TODO (#147)
All checks were successful
check / check (push) Successful in 4s
Reviewed-on: #147
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-06 21:36:13 +02:00
a22f33d511 fix: implement proper view navigation stack (#146)
All checks were successful
check / check (push) Successful in 25s
## Summary

Fixes the view stack pop bug where pressing Back in Settings (or any view) always returned to Main instead of the previous view.

Closes [issue #134](#134)

## Problem

The popup UI had no navigation stack. Every back button was hardcoded to a specific destination (usually Main). The reported path:

> Main → Address → Transaction → Settings (gear icon) → Back

...would go to Main instead of returning to the Transaction view.

## Solution

Implemented a proper view navigation stack (like iOS) as already described in the README:

- **`viewStack`** array added to persisted state — survives popup close/reopen
- **`pushCurrentView()`** — pushes the current view name onto the stack before any forward navigation
- **`goBack()`** — pops the stack and shows the previous view; falls back to Main if the stack is empty; re-renders the wallet list when returning to Main
- **`clearViewStack()`** — resets the stack for root transitions (e.g., after adding/deleting a wallet)

### What Changed

1. **helpers.js** — Added navigation stack functions (`pushCurrentView`, `goBack`, `clearViewStack`, `setRenderMain`)
2. **state.js** — Added `viewStack` to persisted state
3. **index.js** — All `ctx.show*()` wrappers now push before navigating forward; gear button uses stack for toggle behavior
4. **All view back buttons** — Replaced hardcoded destinations with `goBack()` (settings, addressDetail, addressToken, transactionDetail, send, receive, addToken, confirmTx, addWallet, settingsAddToken, deleteWallet, export-privkey)
5. **Direct `showView()` forward navigations** — Added `pushCurrentView()` calls before `showView("send")` in addressDetail, addressToken, and home; before `showView("export-privkey")` in addressDetail; before `deleteWallet.show()` in settings
6. **Reset-to-root transitions** — `clearViewStack()` called after adding a wallet (all 3 import types), after deleting the last wallet, and after transaction completion (Done button)

### Navigation Paths Verified

- **Main → Settings → Back** → returns to Main ✓
- **Main → Address → Settings → Back** → returns to Address ✓
- **Main → Address → Transaction → Settings → Back** → returns to Transaction ✓ (the reported bug)
- **Main → Address → Token → Send → ConfirmTx → Back → Back → Back → Back** → unwinds correctly through each view back to Main ✓
- **Main → Address → Token → Transaction → Settings → Back** → returns to Transaction ✓
- **Settings → Add Wallet → (add) → Main** → stack cleared, fresh root ✓
- **Settings → Delete Wallet → Back** → returns to Settings ✓
- **Settings → Delete Wallet → (confirm)** → stack reset to [main], settings shown ✓
- **Address → Send → ConfirmTx → (broadcast) → SuccessTx → Done** → stack reset, returns to address context ✓
- **Popup close/reopen** → viewStack persisted, back navigation still works ✓

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #146
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 00:15:01 +01:00
39db06c83d feat: show debug banner on testnet or debug mode, add TESTNET tag (#143)
All checks were successful
check / check (push) Successful in 12s
Display the red debug banner when on a testnet OR when DEBUG is enabled.

When on a testnet, a "TESTNET" label is shown on the far right side of the banner. The banner label shows the network name when not in debug mode, and "DEBUG / INSECURE" when debug is on.

closes #140

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #143
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 21:55:36 +01:00
df031fd07d fix: unify address display with shared renderAddressHtml utility (#129)
All checks were successful
check / check (push) Successful in 6s
## Summary

All address rendering now uses a single `renderAddressHtml()` function in helpers.js that produces consistent output everywhere:
- Color dot (deterministic from address)
- Full address with dashed-underline click-to-copy affordance
- Etherscan external link icon

## Changes

Refactored all 9 view files that display addresses to use the shared utility:
- **approval.js** (approve-tx, approve-sign, approve-site): addresses now have click-to-copy with dashed underline affordance
- **confirmTx.js**: from/to addresses and token contract address use shared renderer
- **txStatus.js**: wait/success/error transaction addresses
- **transactionDetail.js**: from/to and decoded calldata addresses
- **home.js**: active address display
- **send.js**: from-address display
- **receive.js**: receive address display
- **addressDetail.js**: address line and export-privkey address
- **addressToken.js**: address line and contract info

## Consolidation

- `EXT_ICON` SVG constant: removed 6 duplicates, now in helpers.js
- `copyableHtml()`: removed duplicate, now in helpers.js
- `etherscanLinkHtml()`: removed duplicates, now in helpers.js
- `attachCopyHandlers()`: removed duplicate, now in helpers.js
- Net: **-193 lines** (174 added, 367 removed)

closes #97

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #129
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 21:54:38 +01:00
a138a36710 fix: suppress USD display on testnet networks (#142)
All checks were successful
check / check (push) Successful in 24s
## Summary

Fixes USD prices still showing on the main view when connected to a testnet (e.g. Sepolia). The root cause was stale mainnet prices lingering in the in-memory price cache after switching networks.

### Root Cause

PR #137 correctly made `refreshPrices()` skip fetching on testnets, but the cached prices from a prior mainnet session remained in the `prices` object. All display functions (`getPrice()`, `getAddressValueUsd()`, etc.) used whatever was cached without checking which network was active.

### Changes

- **`src/shared/prices.js`**
  - `refreshPrices()` now clears the price cache when on a testnet instead of silently returning
  - New `clearPrices()` function empties the cache and resets the fetch timestamp
  - `getPrice()` returns null on testnets (defense-in-depth)
  - `getAddressValueUsd()`, `getWalletValueUsd()`, `getTotalValueUsd()` return null on testnets

- **`src/popup/views/settings.js`**
  - Network switcher immediately clears prices when switching to a testnet, so the UI updates without waiting for the next refresh cycle

closes #139

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #142
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 20:31:45 +01:00
6b40fa8836 feat: show estimated USD value for ETH in approve-tx view (#141)
All checks were successful
check / check (push) Successful in 25s
The approve-tx view (dapp-initiated transaction approval) now shows the estimated USD value next to the ETH amount being transferred, using the existing `getPrice`/`formatUsd` from `shared/prices.js`.

This matches the behavior already present in the manual send confirmation view (`confirmTx.js`).

When ETH price is available, the value line shows e.g. `0.5000 ETH ($1,650.00)`. When price is unavailable, it falls back gracefully to just the ETH amount.

closes #138

Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #141
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 20:16:00 +01:00
bc2aedaab6 fix: etherscan link on address-token page goes to token-specific URL (#136)
All checks were successful
check / check (push) Successful in 9s
When viewing the address-token page for our own address with an ERC-20 token, the etherscan link now navigates to the token-specific page (`etherscan.io/token/<contract>?a=<address>`) instead of the plain address page.

Closes #135

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #136
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 20:13:55 +01:00
e53420f2e2 feat: add Sepolia testnet support (#137)
All checks were successful
check / check (push) Successful in 9s
## Summary

Adds Sepolia testnet support to AutistMask.

### Changes

- **New `src/shared/networks.js`** — centralized network definitions (mainnet + Sepolia) with chain IDs, default RPC/Blockscout endpoints, and block explorer URLs
- **State management** — `networkId` added to persisted state; defaults to mainnet for backward compatibility
- **Settings UI** — network selector dropdown lets users switch between Ethereum Mainnet and Sepolia Testnet
- **Dynamic explorer links** — all hardcoded `etherscan.io` URLs replaced with dynamic links from the current network config (`sepolia.etherscan.io` for Sepolia)
- **Background service** — `wallet_switchEthereumChain` now accepts both mainnet (0x1) and Sepolia (0xaa36a7); broadcasts `chainChanged` to connected dApps
- **Inpage provider** — fetches chain ID on init and updates dynamically via `chainChanged` events (no more hardcoded `0x1`)
- **Blockscout API** — uses `eth-sepolia.blockscout.com/api/v2` for Sepolia
- **Etherscan labels** — phishing/scam checks use the correct explorer per network
- **Price fetching** — skipped on testnets (testnet tokens have no real market value)
- **RPC validation** — checks against the selected network's chain ID, not hardcoded mainnet
- **ethers provider** — `getProvider()` uses the correct ethers `Network` for Sepolia

### API Endpoints Verified

| Service | Mainnet | Sepolia |
|---------|---------|--------|
| Etherscan | etherscan.io | sepolia.etherscan.io |
| Blockscout | eth.blockscout.com/api/v2 | eth-sepolia.blockscout.com/api/v2 |
| RPC | ethereum-rpc.publicnode.com | ethereum-sepolia-rpc.publicnode.com |
| CoinDesk (prices) |  | N/A (skipped on testnet) |

closes #110

Reviewed-on: #137

THIS WAS ONESHOTTED USING OPUS 4.  WTAF
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 20:11:22 +01:00
d35bfb7d23 feat: expand confirm-tx warnings — closes #114 (#118)
All checks were successful
check / check (push) Successful in 5s
Expands the confirm-tx warning system with three new warning types, all using the existing `visibility:hidden/visible` pattern from PR #98 (no animations, no layout shift).

## Changes

1. **Scam address list expanded** (7 → 652 addresses): Sourced from [MyEtherWallet/ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) darklist (MIT license). Checked synchronously before sending.

2. **Contract address warning**: When the recipient is a smart contract (detected via `getCode`), shows a warning that sending directly to a contract may result in permanent loss of funds.

3. **Null/burn address warning**: Detects known burn addresses (`0x0000...0000`, `0x...dead`, `0x...deadbeef`) and warns that funds are permanently destroyed.

4. **No-history warning** (existing from #98): Unchanged, still shows for EOAs with zero transaction history.

All warnings use reserved-space `visibility:hidden/visible` elements — no layout shift, no animations.

closes #114

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #118
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-01 19:34:54 +01:00
3bf60ff162 Reorder transaction detail view: txid first, logical grouping (#133)
All checks were successful
check / check (push) Successful in 9s
Reorganizes the transaction detail view into logical blocks separated by thin horizontal rules for visual clarity.

**Identity block**: Transaction hash (first!), Type, Status
**Timing block**: Time, Block number
**Value block**: Amount, Native quantity, Token contract, From, To
**Decoded details**: Action/protocol/steps (for contract calls)
**Network details**: Nonce, Gas price, Gas used, Transaction fee
**Raw data**: Full calldata

README TransactionDetail screen map updated to reflect the new ordering and grouping.

closes #131

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@git.eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #133
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 19:27:23 +01:00
72 changed files with 240601 additions and 1029 deletions

View File

@@ -1,4 +1,3 @@
.git
node_modules node_modules
.DS_Store .DS_Store
dist dist

View File

@@ -6,4 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build . - run: script/cibuild

View File

@@ -1,13 +1,15 @@
# node:22-slim (22.x LTS), 2026-02-24 # node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare yarn@1.22.22 --activate
WORKDIR /app WORKDIR /app
# script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first
# so the bootstrap layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN script/bootstrap
COPY . . COPY . .

107
LICENSE
View File

@@ -672,3 +672,110 @@ may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>. <https://www.gnu.org/licenses/why-not-lgpl.html>.
===========================================================================
THIRD-PARTY FILES
===========================================================================
The following files are not original to this project and are distributed
under their own licenses. They are NOT covered by the GPL-3.0 license above.
---------------------------------------------------------------------------
File: src/shared/phishingBlocklist.json
Source: https://github.com/AugurProject/eth-phishing-detect (config.json)
Copyright: Copyright (c) 2018 kumavis
License: Don't Be a Dick Public License (DBAD), Version 1.2
---------------------------------------------------------------------------
DON'T BE A DICK PUBLIC LICENSE
Version 1.2, February 2021
Copyright (C) 2018 kumavis
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document.
DON'T BE A DICK PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
1. Do whatever you like with the original work, just don't be a dick.
Being a dick includes - but is not limited to - the following instances:
1a. Outright copyright infringement - Don't just copy the original
work/works and change the name.
1b. Selling the unmodified original with no work done what-so-ever,
that's REALLY being a dick.
1c. Modifying the original work to contain hidden harmful content.
That would make you a PROPER dick.
2. If you become rich through modifications, related works/services, or
supporting the original work, share the love. Only a dick would make
loads off this work and not buy the original work's creator(s) a pint.
3. Code is provided with no warranty. Using somebody else's code and
bitching when it goes wrong makes you a DONKEY dick. Fix the problem
yourself. A non-dick would submit the fix back or submit a bug report.
4. If you use code, calling it your own would make you a ROYAL dick.
Alternatively, even just a comment giving attribution to where you found
the code would be OK.
---------------------------------------------------------------------------
File: src/shared/scamlist.js (address data from MyEtherWallet ethereum-lists)
Source: https://github.com/MyEtherWallet/ethereum-lists (addresses-darklist.json)
Copyright: Copyright (c) 2020 MyEtherWallet
License: MIT License
---------------------------------------------------------------------------
MIT License
Copyright (c) 2020 MyEtherWallet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---------------------------------------------------------------------------
File: src/shared/scamlist.js (address data from EtherScamDB)
Source: https://github.com/MrLuit/EtherScamDB (scams.yaml)
Copyright: Copyright (c) 2018 Luit Hollander
License: MIT License
---------------------------------------------------------------------------
MIT License
Copyright (c) 2018 Luit Hollander
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,29 +1,60 @@
.PHONY: install test lint fmt fmt-check check docker hooks build 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
# of README.md).
bootstrap:
@script/bootstrap
setup:
@script/setup
install: install:
@yarn install @yarn install
test: test:
@echo "Running tests..." @script/test
@timeout 30 yarn run test 2>&1
# Browser end-to-end suite. Requires docker; not part of check.
test-e2e:
@script/test-e2e
lint: lint:
@echo "Linting..." @script/lint
@yarn run lint 2>&1
fmt: fmt:
@echo "Formatting..." @script/fmt
@yarn run fmt 2>&1
fmt-check: fmt-check:
@echo "Checking formatting..." @script/fmt-check
@yarn run fmt-check 2>&1
check: test lint fmt-check check:
@script/check
docker:
@script/docker
hooks:
@script/install-precommit
build: build:
@echo "Building extension..." @echo "Building extension..."
@yarn run build 2>&1 @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
# distribute the artifacts this produces.
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: clean:
@rm -rf dist/ @rm -rf dist/
@@ -31,15 +62,3 @@ clean:
dev: dev:
@echo "Building in watch mode..." @echo "Building in watch mode..."
@yarn run build --watch 2>&1 @yarn run build --watch 2>&1
docker:
@docker build -t autistmask .
hooks:
@echo "Installing pre-commit hook..."
@mkdir -p .git/hooks
@echo '#!/usr/bin/env bash' > .git/hooks/pre-commit
@echo 'set -euo pipefail' >> .git/hooks/pre-commit
@echo 'make check' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."

623
README.md
View File

@@ -15,10 +15,12 @@ Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
Everything you need, nothing you don't. We import as few libraries as possible, Everything you need, nothing you don't. We import as few libraries as possible,
don't implement any crypto, and don't send user-specific data anywhere but a don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The (user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts exactly three external services: the configured RPC node for extension contacts three user-configurable services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and information, and a Blockscout block-explorer API for transaction history and
token balances. All three endpoints are user-configurable. token balances. It also fetches a community-maintained phishing domain blocklist
periodically and performs best-effort Etherscan address label lookups during
transaction confirmation.
In the extension is a hardcoded list of the top ERC20 contract addresses. You In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -40,6 +42,121 @@ Load the extension:
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
Temporary Add-on", and select `dist/firefox/manifest.json`. Temporary Add-on", and select `dist/firefox/manifest.json`.
### Debug Builds
`make build` always produces a release build: the build-time `DEBUG` constant is
`false`, so wallet creation uses real entropy and the red banner is off. To
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:
```bash
make build-debug # or: AUTISTMASK_DEBUG=1 make build
```
Only the exact value `1` enables it; any other value (including unset, empty, or
`true`) yields a release build, so a typo cannot accidentally ship the debug
behavior. The build prints which mode it used. See the
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
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
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
yarn via corepack, `yarn install --frozen-lockfile`)
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
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)
- `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`
- `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 ## Rationale
Common popular EVM wallets have become bloated with swap UIs, portfolio Common popular EVM wallets have become bloated with swap UIs, portfolio
@@ -154,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 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). 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 Text that triggers an action (e.g. "Add additional wallet...") uses an
invisible hit targets, no bare text that happens to have a click handler. If it underline. No invisible hit targets, no bare text that happens to have a click
does something when you click it, it must look like it does something when you handler. If it does something when you click it, it must look like it does
click it. something when you click it.
#### Display Consistency #### Display Consistency
@@ -217,12 +334,18 @@ attack.
The core hierarchy is **Wallets → Addresses**: The core hierarchy is **Wallets → Addresses**:
- A **wallet** is either: - A **wallet** is one of three types:
- An **HD wallet** (recovery phrase): generates multiple addresses from a - An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
can add more addresses with a "+" button. derivation. The user can add more addresses with a "+" button.
- A **key wallet** (private key): a single address imported directly from a - A **key wallet** (`type: "key"`, private key): a single address imported
private key. No "+" button since there is only one address. 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. - An **address** holds ETH and any user-added ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a - The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key). single address (key).
@@ -237,95 +360,140 @@ menus.
### Screen Map ### Screen Map
Navigation uses a stack model (like iOS): each action pushes a screen onto the Navigation uses a stack model (like iOS): each forward action pushes the current
stack, and "Back" pops it. The root screen is either Welcome (no wallets) or screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
Home (has wallets). Screens are listed below with their elements and `goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
transitions. (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. Closing and reopening the popup returns to the screen the user was last on only
- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button. 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**: - **Transitions**:
- "Add wallet" → **AddWallet** - "Add wallet" → **AddWallet**
#### Home #### Home (`main`)
- **When**: At least one wallet exists. This is the root screen. - **When**: At least one wallet exists. This is the root screen.
- **Elements**: - **Elements**:
- Header: "AutistMask", Settings gear button - Active address ETH balance (large) + USD value in parentheses
- Active address ETH balance (large) + USD value (inline parentheses) - "Total:" USD value across ETH and all tracked tokens of the active address
- Total USD value across all tokens (small text)
- Active address (color dot, full address, etherscan link, tap to copy) - 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 - ETH/USD price display
- Wallet list: each wallet shows name (tap to rename), "+" button (HD only), - Wallet list: each wallet shows its name (tap to rename inline) and a "+"
and its addresses with color dots, balances, and `[info]` buttons button for HD and xprv wallets, then one block per address with "Address
- Recent transactions across all addresses (merged, deduplicated, filtered) 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 - "Add additional wallet..." link at bottom
- **Transitions**: - **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** - `[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) - "Receive" → **Receive** (shows active address QR)
- "+" on wallet → derives next address inline - Tap home tx row → **TransactionDetail**
- "Add additional wallet..." → **AddWallet** - "Add additional wallet..." → **AddWallet**
- Settings gear → **Settings** (toggles; tap again to return) - 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**: - **Elements**:
- "Add Wallet" heading, "Back" button - "Back" button, "Add Wallet" heading
- Instruction text - Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
- Die button `[die]` (generates random recovery phrase) "From xprv" (`tab-xprv`) — each showing its own form section:
- Recovery phrase textarea - **From Phrase**: instruction text, a die button that generates a
- Backup warning box (shown after die is clicked) random recovery phrase, a recovery phrase textarea, and a backup
- Password + confirm password inputs warning box that becomes visible once the die button has been used
- "Add" button - **From Key**: instruction text and a masked private key input
- "Have a private key instead?" link - **From xprv**: instruction text and a masked extended private key
- **Transitions**: input
- "Add" (valid phrase + password) → **Home** - Password + confirm password inputs, with a hint line whose wording depends
- "Back" → previous screen (Home or Welcome) on the selected tab
- "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
- "Import" button - "Import" button
- **Transitions**: - **Transitions**:
- "Import" (valid key + password) → **Home** - "Import" with a valid entry and a matching password of at least 12
- "Back" → **AddWallet** 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. - **When**: User tapped `[info]` on an address from Home.
- **Elements**: - **Elements**:
- "Back" button - "Back" button
- Blockie identicon (48px, centered) - Blockie identicon (48px, centered)
- Title: "Wallet Name — Address N" - 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) - Full address (color dot, etherscan link, tap to copy)
- USD total for address - USD total for address
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline). - Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
Each balance row is clickable → **AddressToken** 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) - Transaction list (with ENS resolution for counterparties)
- **Transitions**: - **Transitions**:
- Tap balance row → **AddressToken** (for that token) - Tap balance row → **AddressToken** (for that token)
- "Send" → **Send** - "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive** - "Receive" → **Receive**
- "+ Token" → **AddToken** - "+ Token" → **AddToken**
- "···" → "Export Private Key" → **ExportPrivKey**
- Tap transaction row → **TransactionDetail** - 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. - **When**: User clicked a specific token balance on AddressDetail.
- **Elements**: - **Elements**:
@@ -336,49 +504,64 @@ transitions.
- USD total for this token - USD total for this token
- Single token balance line (4 decimal places) - Single token balance line (4 decimal places)
- Send / Receive buttons - 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) - Token-filtered transaction list (only this token's transfers)
- **Transitions**: - **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) - "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
- Tap transaction row → **TransactionDetail** - 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**: - **Elements**:
- "Send" heading, "Back" button - "Back" button, "Send" heading
- From: address with color dot + etherscan link - From: address with color dot + etherscan link
- What to send: token dropdown (or static display with contract address when - What to send: token dropdown (or static display with contract address when
locked from AddressToken) 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 - Amount input with current balance display
- "Review" button - "Review" button, disabled until the recipient validates
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "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. - **When**: User reviewed send details and is ready to authorize.
- **Elements**: - **Elements**:
- "Confirm Transaction" heading, "Back" button - "Back" button, "Confirm Transaction" heading
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)" - Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
- Token contract: full address + etherscan link (ERC-20 only) - Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title - From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name - To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses) - Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses) - Your balance: value + symbol (USD in parentheses)
- Estimated network fee: ETH amount (USD in parentheses), fetched async - Estimated network fee: "Estimating..." then the ETH amount (USD in
- Warnings (scam address, self-send) 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) - 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**: - **Transitions**:
- "Send" → password modal → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Send" → password modal → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Back" → **Send** - "Back" → **Send**
#### WaitTx #### WaitTx (`wait-tx`)
- **When**: Transaction has been broadcast, waiting for on-chain confirmation. - **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**: - **Elements**:
@@ -392,20 +575,24 @@ transitions.
- Receipt found → **SuccessTx** - Receipt found → **SuccessTx**
- 60 seconds without confirmation → **ErrorTx** (timeout message) - 60 seconds without confirmation → **ErrorTx** (timeout message)
#### SuccessTx #### SuccessTx (`success-tx`)
- **When**: Transaction confirmed on-chain. - **When**: Transaction confirmed on-chain.
- **Elements**: - **Elements**:
- "Transaction Confirmed" heading - "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 - Amount + symbol
- To: color dot + full address + etherscan link - To: color dot + full address + etherscan link
- Block number - Block number
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Transitions**: - **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. - **When**: Transaction broadcast failed, or timed out waiting for confirmation.
- **Elements**: - **Elements**:
@@ -417,129 +604,228 @@ transitions.
full hash (tap to copy) + etherscan link full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Transitions**: - **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**: - **Elements**:
- "Receive" heading, "Back" button - "Back" button, "Receive" heading
- Instruction text - Instruction text
- QR code encoding the address - QR code encoding the address
- Full address (color dot, selectable, etherscan link) - Full address (color dot, selectable, etherscan link)
- "Copy address" button - "Copy address" button
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token) - ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
- **Transitions**: - **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
- **Elements**: AddressToken.
- **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
- Transaction hash: full hash (tap to copy) + etherscan link
- Type: transaction classification — one of: Native ETH Transfer, ERC-20 - Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
- Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
name if available
- Time: ISO datetime + relative age in parentheses
- Block: block number (tap to copy) + etherscan block link
- Amount: value + symbol (bold)
- Native quantity: raw integer + unit (shown when available)
- Token contract: shown for ERC-20 transfers — color dot + full contract - Token contract: shown for ERC-20 transfers — color dot + full contract
address (tap to copy) + etherscan token link address (tap to copy) + etherscan token link
- Status: "Success" or "Failed" - Decoded details (shown for contract calls): action name, decoded
- Time: ISO datetime + relative age in parentheses parameters, token details, swap steps
- Amount: value + symbol (bold) - Network details (shown when on-chain data is available): nonce, gas price,
- From: blockie + color dot + full address (tap to copy) + etherscan link gas used, transaction fee (all tap to copy)
- ENS name if available - Raw data (shown when calldata is present): full calldata in monospace
- To: blockie + color dot + full address (tap to copy) + etherscan link dashed border
- ENS name if available
- Transaction hash: full hash (tap to copy) + etherscan link
- Block: block number (tap to copy) + etherscan block link
- Nonce: transaction nonce (tap to copy)
- Transaction fee: ETH amount (tap to copy)
- Gas price: value in Gwei (tap to copy)
- Gas used: integer (tap to copy)
- **Transitions**: - **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**: - **Elements**:
- "Add Token" heading, "Back" button - "Back" button, "Add Token" heading
- Instruction text (find contract address on Etherscan) - Instruction text (find contract address on Etherscan)
- Contract address input - Contract address input
- Token info preview (name, symbol — fetched from contract) - Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons - Common token quick-pick buttons (top 25 by market cap), which fill the
contract address input
- "Add" button - "Add" button
- **Transitions**: - **Transitions**:
- "Add" (valid contract) → **AddressDetail** - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
- "Back" → **AddressDetail** **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**: - **Elements**:
- "Settings" heading, "Back" button - "Back" button, "Settings" heading
- Wallets: "+ Add wallet" button - Wallets: one row per wallet with its name (tap to rename inline) and an
- Display: "Show tracked tokens with zero balance" checkbox `[x]` delete button, plus a "+ Add wallet" button
- Ethereum RPC: endpoint URL input + "Save" button - Tracked Tokens: one row per tracked token with an `[x]` remove button,
- Blockscout API: endpoint URL input + "Save" 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: - Token Spam Protection:
- "Hide tokens with fewer than 1,000 holders" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox - "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input - "Hide dust transactions below N gwei" checkbox + threshold input
- "UTC Timestamps" checkbox
- Allowed Sites: list with remove buttons - Allowed Sites: list with remove buttons
- Denied 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**: - **Transitions**:
- "+ Add wallet" → **AddWallet** - "+ 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 - **When**: User tapped the `[x]` next to a wallet in Settings.
in a separate popup by the background script. - **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**: - **Elements**:
- "Connection Request" heading - "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) - Address that will be shared (color dot + full address + etherscan link)
- "Remember my choice for this site" checkbox - "Remember my choice for this site" checkbox
- "Allow" / "Deny" buttons - "Allow" / "Deny" buttons
- **Transitions**: - **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 - **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**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to send a transaction" - Site hostname (bold) + "wants to send a transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding) amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link - 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 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) - Raw data: full calldata displayed inline (shown if present)
- Password input - Password input and an error line
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Transitions**: - **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) - "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 - **When**: A connected website requests a message signature via
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
popup by the background script. TxApproval, in a separate popup window.
- **Elements**: - **Elements**:
- "Signature Request" heading - "Signature Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants you to sign a message" - 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)" - Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/ - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data) message fields (EIP-712 typed data)
- Password input - Password input and an error line
- "Sign" / "Reject" buttons - "Sign" / "Reject" buttons
- **Transitions**: - **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) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
### External Services ### External Services
@@ -576,14 +862,25 @@ What the extension does NOT do:
- No analytics or telemetry services - No analytics or telemetry services
- No token list APIs (user adds tokens manually by contract address) - No token list APIs (user adds tokens manually by contract address)
- No phishing/blocklist APIs
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are In addition to the three user-configurable services above (RPC endpoint,
the only external services. All three endpoints are user-configurable. Users who CoinDesk price API, and Blockscout API), AutistMask also contacts:
want maximum privacy can point the RPC and Blockscout URLs at their own
self-hosted instances (price fetching can be disabled in a future version). - **Phishing domain blocklist**: A community-maintained phishing domain
blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added
domains. Only the delta (domains not already in the vendored list) is kept in
memory, keeping runtime memory usage small. The delta is persisted to
localStorage if it is under 256 KiB.
- **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the
user's browser makes the request.
Users who want maximum privacy can point the RPC and Blockscout URLs at their
own self-hosted instances (price fetching can be disabled in a future version).
### Dependencies ### Dependencies
@@ -628,6 +925,19 @@ flows, or alter program behavior beyond the banner and the hardcoded mnemonic.
Adding new DEBUG-conditional branches requires explicit approval from the Adding new DEBUG-conditional branches requires explicit approval from the
project owner. project owner.
`DEBUG` is a build-time constant, not a runtime setting. `build.js` injects it
into the bundle as the `__BUILD_DEBUG__` define — `false` unless the build was
run with `AUTISTMASK_DEBUG=1` (see [Debug Builds](#debug-builds)) — and
`src/shared/constants.js` reads it. It cannot be changed after the bundle is
produced.
The debug-mode toggle in settings is a separate, runtime-only flag. It raises
the log level and turns the banner on, and that is all it may ever do: it feeds
`isDebug()` in `src/shared/log.js`, which is deliberately not what
`generateMnemonic()` consults. Mnemonic generation reads the build-time `DEBUG`
constant directly, so no runtime toggle in a release build can reach the
hardcoded test phrase.
### Key Decisions ### Key Decisions
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small - **No framework**: The popup UI is vanilla JS and HTML. The extension is small
@@ -670,6 +980,7 @@ project owner.
- Create new HD wallet (generates 12-word recovery phrase) - Create new HD wallet (generates 12-word recovery phrase)
- Import HD wallet from existing 12 or 24 word recovery phrase - Import HD wallet from existing 12 or 24 word recovery phrase
- Import single-address wallet from private key - Import single-address wallet from private key
- Import multi-address wallet from an extended private key (`xprv`)
- Add multiple addresses within an HD wallet - Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously - Manage multiple wallets simultaneously
- View ETH balance per address - View ETH balance per address
@@ -773,6 +1084,21 @@ indexes it as a real token transfer.
designed as a sharp tool — users who understand the risks can configure the designed as a sharp tool — users who understand the risks can configure the
wallet to show everything unfiltered, unix-style. wallet to show everything unfiltered, unix-style.
#### Phishing Domain Protection
AutistMask protects users from known phishing sites when they connect their
wallet or approve transactions/signatures. A community-maintained domain
blocklist is vendored into the extension at build time, providing immediate
protection without any network requests. At runtime, the extension fetches the
live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching).
#### Transaction Decoding #### Transaction Decoding
When a dApp asks the user to approve a transaction, AutistMask attempts to When a dApp asks the user to approve a transaction, AutistMask attempts to
@@ -820,13 +1146,13 @@ Currently supported:
### Wallet Management ### Wallet Management
- [ ] Delete wallet (with confirmation) - [x] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation) - [ ] Delete address from HD wallet (with confirmation)
- [ ] Show wallet's recovery phrase (requires password) - [ ] Show wallet's recovery phrase (requires password)
### Transactions ### Transactions
- [ ] Gas estimation and fee display before confirming - [x] Gas estimation and fee display before confirming
### Testing ### Testing
@@ -855,6 +1181,21 @@ Currently supported:
GPL-3.0. See [LICENSE](LICENSE). GPL-3.0. See [LICENSE](LICENSE).
### Third-Party Data Files
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 |
The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file.
## Author ## Author
[@sneak](https://sneak.berlin) [@sneak](https://sneak.berlin)

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-02-22 last_modified: 2026-07-06
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,10 +34,46 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make hooks` (installs pre-commit hook). A model Makefile is at `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -57,11 +93,83 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build `make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`, runs `script/cibuild` (which runs `docker build .`) on push. Since the
a successful build implies all checks pass. Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -69,9 +177,11 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise - Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
`make lint && make fmt-check`. The Makefile should provide a `make hooks` testing is not possible in the repo, `script/precommit` may skip `script/test`
target to install the pre-commit hook. and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -82,6 +192,42 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile. Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes. - Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary - `make check` must not modify any files in the repo. Tests may use temporary
@@ -98,6 +244,13 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -121,12 +274,76 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with - Dockerized web services listen on port 8080 by default, overridable with
`PORT`. `PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -144,8 +361,14 @@ style conventions are in separate documents:
- Use SemVer. - Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed). the binary.
Post-1.0.0: add new migration files. - `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings.
@@ -175,6 +398,9 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`

123
TODO.md Normal file
View File

@@ -0,0 +1,123 @@
# Workflow
- `git pull` `next` and cut a branch from it — one branch per issue, named
`issue-<N>-<slug>`. 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. 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 [#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: `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: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: 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)).
- 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
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)
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-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
shims, README Entrypoints section (#148)
- 2026-03-01: USD display suppressed on testnets (#142); estimated USD for ETH
in approve-tx view (#141).
- Sepolia testnet support (#137); etherscan links go to token-specific URLs
(#136).
- Transaction detail improvements: Type field and on-chain details (#130),
txid-first reordering (#133), swap display corrections (#128), expanded
confirm-tx warnings (#118).
- Dark mode theme setting (Light/Dark/System) with contrast fixes (#126);
timestamps include timezone offset (#120); layout shift audit, reserved space
for error messages (#124).
- Copy-flash visual feedback with timing tune (#113, #121); cross-wallet-type
duplicate detection (#115).
- 2026-02-27: v0.1.0 tagged.
- 2026-02-24: Initial scaffolding: popup UI, BIP-39 wallet creation via
ethers.js, wallet persistence, real ETH balances over RPC, ENS forward and
reverse resolution.
# Future Steps
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. 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.

189
build.js
View File

@@ -3,75 +3,174 @@ const path = require("path");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const DIST_CHROME = path.join(__dirname, "dist", "chrome"); const DIST = path.join(__dirname, "dist");
const DIST_FIREFOX = path.join(__dirname, "dist", "firefox"); const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src"); 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) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); 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.
//
// 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]) => {
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)
// produces a release build. Failing towards the safe mode is deliberate.
function isDebugBuild() {
return process.env.AUTISTMASK_DEBUG === "1";
}
function getBuildInfo() {
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
let commitHash = "unknown";
try {
commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
// not a git repo or git not available
}
let commitHashFull = "unknown";
try {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
// not a git repo or git not available
}
return {
version: pkg.version,
license: pkg.license,
author: pkg.author,
commitHash,
commitHashFull,
buildDate: new Date().toISOString().slice(0, 10),
};
}
async function build() { async function build() {
console.log("Building AutistMask extension..."); console.log("Building AutistMask extension...");
const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo);
const debugBuild = isDebugBuild();
console.log(
debugBuild
? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)"
: "Build mode: release (DEBUG off)",
);
const define = {
__BUILD_DEBUG__: JSON.stringify(debugBuild),
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
__BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash),
__BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull),
__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 // compile tailwind CSS
console.log("Compiling Tailwind CSS..."); console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(__dirname, "dist", "styles.css"); const tailwindOutput = path.join(DIST, "styles.css");
ensureDir(path.join(__dirname, "dist")); 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( execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, `npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
{ stdio: "inherit" }, { 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]) { for (const distDir of [DIST_CHROME, DIST_FIREFOX]) {
ensureDir(path.join(distDir, "src", "popup")); ensureDir(path.join(distDir, "src", "popup"));
ensureDir(path.join(distDir, "src", "background")); ensureDir(path.join(distDir, "src", "background"));
ensureDir(path.join(distDir, "src", "content")); ensureDir(path.join(distDir, "src", "content"));
// bundle popup JS with esbuild (inlines ethers, libsodium, etc.) // bundle popup JS with esbuild (inlines ethers, libsodium, etc.)
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "popup", "index.js")], path.join(SRC, "popup", "index.js"),
bundle: true, path.join(distDir, "src", "popup", "index.js"),
format: "iife", );
outfile: path.join(distDir, "src", "popup", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// bundle background script // bundle background script
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "background", "index.js")], path.join(SRC, "background", "index.js"),
bundle: true, path.join(distDir, "src", "background", "index.js"),
format: "iife", );
outfile: path.join(distDir, "src", "background", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// bundle content script // bundle content script
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "content", "index.js")], path.join(SRC, "content", "index.js"),
bundle: true, path.join(distDir, "src", "content", "index.js"),
format: "iife", );
outfile: path.join(distDir, "src", "content", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// bundle inpage script (injected into page context, separate file) // bundle inpage script (injected into page context, separate file)
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "content", "inpage.js")], path.join(SRC, "content", "inpage.js"),
bundle: true, path.join(distDir, "src", "content", "inpage.js"),
format: "iife", );
outfile: path.join(distDir, "src", "content", "inpage.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// copy popup HTML // copy popup HTML
fs.copyFileSync( fs.copyFileSync(
@@ -96,6 +195,16 @@ async function build() {
path.join(DIST_FIREFOX, "manifest.json"), 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/"); console.log("Build complete: dist/chrome/ and dist/firefox/");
} }

View File

@@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else.
## Why AutistMask Exists ## Why AutistMask Exists
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics, The most popular browser-based EVM wallet has become bloated with swap UIs,
tracking, and advertisements. It is no longer a simple wallet. Most alternatives portfolio dashboards, analytics, tracking, and advertisements. It is no longer a
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users simple wallet. The common alternatives only support Chromium browsers, leaving
without a usable option. Firefox users without a usable option.
AutistMask exists because a wallet should be a wallet. You should be able to see 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 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 - **Encrypt your recovery phrase and private keys at rest.** Your secrets are
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305 encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
authenticated encryption (via libsodium). Your password is required only when authenticated encryption (via libsodium). Your password is required whenever a
signing a transaction. Viewing balances and addresses never requires a secret has to be decrypted: signing a transaction, signing a message or typed
password. 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 - **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 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 NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No token auto-discovery.** AutistMask does not scan the blockchain for - **No third-party token list APIs.** Token balances come from the same block
tokens you might hold. You add tokens manually by contract address. This explorer you configure for transaction history, and the extension ships its
prevents scam tokens from appearing in your wallet uninvited. 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 - **No backend servers operated by the developer.** Nothing is sent to any
to check URLs against a remote blocklist. It does maintain a local list of server run by AutistMask. Every network destination is listed below.
known scam addresses, but this is shipped with the extension, not fetched from
a server.
## How It Works ## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. 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 ### 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 This is how AutistMask talks to the Ethereum network. Every wallet needs an
Ethereum node to check balances, estimate gas, broadcast transactions, and 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 this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node. node.
What gets sent: standard Ethereum JSON-RPC requests (balance queries, When it is contacted: on every balance refresh (every 10 seconds while the popup
transaction broadcasts, gas estimates, ENS lookups). Your addresses are is open, every 60 seconds in the background), when you type an ENS name into the
necessarily visible to the RPC provider when querying balances. 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 Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token 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. 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). What gets sent: your Ethereum addresses (to look up balances and transactions).
**CoinDesk CADLI price API** (`data-api.coindesk.com`) **CoinDesk CADLI price API** (`data-api.coindesk.com`)
Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
for 5 minutes. No API key is required. No user data is sent -- only a list of cached for 5 minutes. No API key is required. This endpoint is not
token symbols (e.g. "ETH", "USDC") to get their prices. 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 When it is contacted: while the popup is open, at most once every 5 minutes.
information.
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 ### 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 password. The password only protects the copy stored in this browser. If you
lose your recovery phrase, your password cannot help you recover it. lose your recovery phrase, your password cannot help you recover it.
Your password is only requested when you send a transaction. Viewing balances, Your password is requested whenever an encrypted secret must be decrypted: when
receiving funds, and browsing transaction history never require your password. 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 ## Installation
@@ -147,34 +199,46 @@ receiving funds, and browsing transaction history never require your password.
### Creating a New Wallet ### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar. 1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet". 2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
3. Click the die button to generate a random 12-word recovery phrase. 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 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 words can take your funds. If you lose them, your wallet is gone. AutistMask
cannot recover them for you. cannot recover them for you.
5. Choose a password. This encrypts your recovery phrase on this device. 5. Choose a password and confirm it. This encrypts your recovery phrase on this
6. Click "Add". device.
6. Click "Import".
### Importing an Existing Wallet ### Importing an Existing Wallet
**From a recovery phrase:** Follow the same steps as creating a wallet, but The Add Wallet screen has three tabs:
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.
**From a private key:** On the Add Wallet screen, click "Have a private key **From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of
instead?" and paste your private key. This creates a single-address wallet. 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 ### Adding More Addresses
HD wallets (created from a recovery phrase) can derive multiple addresses. On HD wallets (created from a recovery phrase or an xprv) can derive multiple
the home screen, click the "+" button next to a wallet name to add the next addresses. On the home screen, click the "+" button next to a wallet name to add
address. These are deterministic -- the same recovery phrase will always produce the next address. These are deterministic -- the same recovery phrase will
the same sequence of addresses. always produce the same sequence of addresses.
### Adding ERC-20 Tokens ### 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). 1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token". 2. Click "+ Token".
@@ -183,12 +247,13 @@ AutistMask does not auto-discover tokens. To track a token:
4. Click "Add". 4. Click "Add".
The token balance will appear on the address detail screen and on the home 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 ## Sending
1. Click "Send" from the home screen or an address detail view. 1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH or 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`). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount. 4. Enter the amount.
5. Click "Review" to see the confirmation screen. 5. Click "Review" to see the confirmation screen.
@@ -201,11 +266,14 @@ The confirmation screen shows:
- **Amount** with USD estimate - **Amount** with USD estimate
- **Your current balance** with USD estimate - **Your current balance** with USD estimate
- **Estimated network fee** in ETH 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 After reviewing, enter your password and click "Sign & Send". The transaction
broadcast to the network and you will see a waiting screen with a timer. Once will be broadcast to the network and you will see a waiting screen with a timer.
confirmed (or after 60 seconds), you will see either a success or error screen Once confirmed (or after 60 seconds), you will see either a success or error
with the transaction hash and an Etherscan link. screen with the transaction hash and an Etherscan link.
### Sending a Specific Token ### 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. 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. 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 When receiving ERC-20 tokens, make sure the sender is sending on the network you
network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens
(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will
permanently lost. not appear and may be permanently lost.
## Connecting to Web3 Sites ## 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 When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data). You must enter your 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 You can manage site permissions in Settings. Allowed and denied sites can be
individually removed to reset their permissions. 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 AutistMask includes several defenses against common Ethereum scams, all enabled
by default: by default:
**Known token symbol verification.** AutistMask ships a list of ~250 legitimate **Known token symbol verification.** AutistMask ships a list of roughly 500
ERC-20 tokens with their contract addresses. If a transaction claims to involve legitimate ERC-20 tokens with their contract addresses. If a transaction or
a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract, balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
it is identified as a spoof and hidden. an unrecognized contract, it is identified as a spoof and hidden.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are 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 from transaction history and the send token list, and are left out of your
substantial holder counts; scam tokens deployed for address poisoning typically balances unless they are on the bundled known-token list or you added them
have zero. 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 **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
@@ -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 plant them in your transaction history. The threshold is configurable in
Settings. 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. see everything unfiltered.
## Settings ## Settings
Click the gear icon on the home screen to access settings: Click the gear icon on the home screen to access settings:
- **Wallets**: Add a new wallet. - **Wallets**: Your wallets, and "+ Add wallet".
- **Display**: Toggle whether tracked tokens with zero balance are shown. - **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. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy. You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and - **Blockscout API**: Change the Blockscout instance used for token balances and
transaction history. You can use a self-hosted instance. transaction history. You can use a self-hosted instance.
- **Token Spam Protection**: Toggle individual scam filters and set the dust - **Token Spam Protection**: Toggle individual scam filters, set the dust
transaction threshold. transaction threshold, and switch timestamps to UTC.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions. - **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 ## 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 Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing
you import the same recovery phrase, you will get the same addresses. You can the same recovery phrase gives you the same addresses as any other wallet using
use both wallets side by side, though only one can be the active that path. Two wallet extensions can be installed side by side, though only one
`window.ethereum` provider at a time. can be the active `window.ethereum` provider at a time.
**Can I use AutistMask with a hardware wallet?** **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?** **Does AutistMask support networks other than Ethereum mainnet?**
Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other
added in the future. networks are supported today. On Sepolia, USD values are not shown, because
testnet tokens have no market value.
**Where is my data stored?** **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 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 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?** **What happens if a transaction times out?**

View File

@@ -16,6 +16,7 @@
"@tailwindcss/cli": "^4.2.1", "@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"jest": "^30.2.0", "jest": "^30.2.0",
"playwright-core": "1.56.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tailwindcss": "^4.2.1" "tailwindcss": "^4.2.1"
}, },

143
script/bootstrap Executable file
View File

@@ -0,0 +1,143 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete"
}
main "$@"

14
script/check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

13
script/cibuild Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

14
script/docker Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

13
script/fmt Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Formatting..."
yarn run fmt 2>&1
}
main "$@"

14
script/fmt-check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Checking formatting..."
yarn run fmt-check 2>&1
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

13
script/lint Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Linting..."
yarn run lint 2>&1
}
main "$@"

12
script/precommit Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "autistmask"
}
main "$@"

13
script/setup Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

13
script/test Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Running tests..."
timeout 30 yarn run test 2>&1
}
main "$@"

64
script/test-e2e Executable file
View File

@@ -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 "$@"

212
script/verify-build Executable file
View File

@@ -0,0 +1,212 @@
#!/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
}
# 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() {
_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. 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 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
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 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() {
_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 is_listed "$_file"; 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 <<EOF
$_listing
EOF
}
# The requested mode, read from our own environment using build.js's exact
# rule: only the literal 1 opts in. Deliberately not taken from anything
# build.js records about itself, so build.js cannot vouch for build.js.
expected_marker() {
if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then
echo "$MARKER_ON"
else
echo "$MARKER_OFF"
fi
}
main() {
cd "$ROOT"
expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
[ -f "$MANIFEST" ] ||
fail "$MANIFEST is missing. build.js writes it at the end of a
successful build; run make build first."
[ -s "$MANIFEST" ] ||
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."
echo " ok: $file ($MARKER)"
count=$((count + 1))
done <"$MANIFEST"
[ "$count" -gt 0 ] || fail "no bundles were inspected."
check_unlisted_bundles
echo "verify-build: $count bundle(s) verified $expected"
}
main "$@"

View File

@@ -2,16 +2,23 @@
// Handles EIP-1193 RPC requests from content scripts and proxies // Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint. // non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const { const {
ETHEREUM_MAINNET_CHAIN_ID, state,
DEFAULT_RPC_URL, loadState,
} = require("../shared/constants"); saveState,
const { getBytes } = require("ethers"); currentNetwork,
const { state, loadState, saveState } = require("../shared/state"); } = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances"); const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log"); const { debugFetch } = require("../shared/log");
const { decryptWithPassword } = require("../shared/vault"); const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const { getSignerForAddress } = require("../shared/wallet"); const {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
} = require("../shared/phishingDomains");
const storageApi = const storageApi =
typeof browser !== "undefined" typeof browser !== "undefined"
@@ -324,31 +331,43 @@ async function handleRpc(method, params, origin) {
} }
if (method === "eth_chainId") { if (method === "eth_chainId") {
return { result: ETHEREUM_MAINNET_CHAIN_ID }; return { result: currentNetwork().chainId };
} }
if (method === "net_version") { if (method === "net_version") {
return { result: "1" }; return { result: currentNetwork().networkVersion };
} }
if (method === "wallet_switchEthereumChain") { if (method === "wallet_switchEthereumChain") {
const chainId = params?.[0]?.chainId; const chainId = params?.[0]?.chainId;
if (chainId === ETHEREUM_MAINNET_CHAIN_ID) { if (chainId === currentNetwork().chainId) {
return { result: null };
}
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId);
await onChainSwitch(target.id);
broadcastChainChanged(target.chainId);
return { result: null }; return { result: null };
} }
return { return {
error: { error: {
code: 4902, code: 4902,
message: "AutistMask only supports Ethereum mainnet.", message:
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
}, },
}; };
} }
if (method === "wallet_addEthereumChain") { if (method === "wallet_addEthereumChain") {
const chainId = params?.[0]?.chainId;
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
return { result: null };
}
return { return {
error: { error: {
code: 4902, code: 4902,
message: "AutistMask only supports Ethereum mainnet.", message:
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
}, },
}; };
} }
@@ -494,6 +513,27 @@ async function handleRpc(method, params, origin) {
return { error: { message: "Unsupported method: " + method } }; return { error: { message: "Unsupported method: " + method } };
} }
// Broadcast chainChanged to all tabs when the network is switched.
function broadcastChainChanged(chainId) {
tabsApi.query({}, (tabs) => {
for (const tab of tabs) {
tabsApi.sendMessage(
tab.id,
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: chainId,
},
() => {
if (runtime.lastError) {
// expected for tabs without our content script
}
},
);
}
});
}
// Broadcast accountsChanged to all tabs, respecting per-address permissions // Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() { async function broadcastAccountsChanged() {
// Clear non-remembered approvals on address switch // Clear non-remembered approvals on address switch
@@ -571,6 +611,11 @@ async function backgroundRefresh() {
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Fetch the phishing domain blocklist delta on startup and refresh every 24h.
// The vendored blocklist is bundled at build time; this fetches only new entries.
updatePhishingList();
startPeriodicRefresh();
// When approval window is closed without a response, treat as rejection // When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) { if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => { windowsApi.onRemoved.addListener((windowId) => {
@@ -643,6 +688,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.type = "sign"; resp.type = "sign";
resp.signParams = approval.signParams; resp.signParams = approval.signParams;
} }
// Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
sendResponse(resp); sendResponse(resp);
} else { } else {
sendResponse(null); sendResponse(null);
@@ -676,39 +723,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true; 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 () => { (async () => {
try { try {
await loadState(); await loadState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
let wallet, addrIndex; // The popup holds the secret, but the background stays the
for (const w of state.wallets) { // authority on what is broadcast: the raw transaction must be
for (let i = 0; i < w.addresses.length; i++) { // the approved one, signed by the approved address.
if (w.addresses[i].address === activeAddress) { verifySignedTx(
wallet = w; msg.rawSignedTx,
addrIndex = i; approval.txParams,
break; activeAddress,
}
}
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 provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
const tx = await connected.sendTransaction(approval.txParams);
approval.resolve({ txHash: tx.hash }); approval.resolve({ txHash: tx.hash });
sendResponse({ txHash: tx.hash }); sendResponse({ txHash: tx.hash });
} catch (e) { } catch (e) {
@@ -735,55 +771,23 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true; 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 () => { (async () => {
try { try {
await loadState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
let wallet, addrIndex; // The popup holds the secret, but the background stays the
for (const w of state.wallets) { // authority on what is handed back to the page: the signature
for (let i = 0; i < w.addresses.length; i++) { // must cover the approved payload and recover to the approved
if (w.addresses[i].address === activeAddress) { // address.
wallet = w; const signature = msg.signature;
addrIndex = i; verifySignature(approval.signParams, signature, activeAddress);
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,
);
}
approval.resolve({ signature }); approval.resolve({ signature });
sendResponse({ signature }); sendResponse({ signature });
} catch (e) { } catch (e) {

View File

@@ -2,7 +2,10 @@
// Creates window.ethereum (EIP-1193 provider) and announces via EIP-6963. // Creates window.ethereum (EIP-1193 provider) and announces via EIP-6963.
(function () { (function () {
const CHAIN_ID = "0x1"; // Ethereum mainnet // Defaults to mainnet; updated dynamically via eth_chainId on init and
// chainChanged events from the extension.
let currentChainId = "0x1";
let currentNetworkVersion = "1";
const listeners = {}; const listeners = {};
let nextId = 1; let nextId = 1;
@@ -28,6 +31,12 @@
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return; if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data; const { eventName, data } = event.data;
if (eventName === "chainChanged") {
currentChainId = data;
currentNetworkVersion = String(parseInt(data, 16));
provider.chainId = currentChainId;
provider.networkVersion = currentNetworkVersion;
}
emit(eventName, data); emit(eventName, data);
}); });
@@ -57,8 +66,8 @@
const provider = { const provider = {
isAutistMask: true, isAutistMask: true,
isMetaMask: true, // compatibility — many dApps check this isMetaMask: true, // compatibility — many dApps check this
chainId: CHAIN_ID, chainId: currentChainId,
networkVersion: "1", networkVersion: currentNetworkVersion,
selectedAddress: null, selectedAddress: null,
async request(args) { async request(args) {
@@ -75,6 +84,12 @@
? result[0] ? result[0]
: null; : null;
} }
if (args.method === "eth_chainId" && result) {
currentChainId = result;
currentNetworkVersion = String(parseInt(result, 16));
provider.chainId = currentChainId;
provider.networkVersion = currentNetworkVersion;
}
return result; return result;
}, },
@@ -189,4 +204,19 @@
window.addEventListener("eip6963:requestProvider", announceProvider); window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider(); announceProvider();
// Fetch the current chain ID from the extension on load so the provider
// reflects the selected network immediately (covers Sepolia etc.).
sendRequest({ method: "eth_chainId", params: [] })
.then((chainId) => {
if (chainId) {
currentChainId = chainId;
currentNetworkVersion = String(parseInt(chainId, 16));
provider.chainId = currentChainId;
provider.networkVersion = currentNetworkVersion;
}
})
.catch(() => {
// Best-effort — keep defaults.
});
})(); })();

View File

@@ -605,6 +605,43 @@
Double-check the address before sending. Double-check the address before sending.
</div> </div>
</div> </div>
<div
id="confirm-contract-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient is a smart contract. Sending ETH
or tokens directly to a contract may result in permanent
loss of funds.
</div>
</div>
<div
id="confirm-burn-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: This is a known null/burn address. Funds sent
here are permanently destroyed and cannot be recovered.
</div>
</div>
<div
id="confirm-etherscan-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: Etherscan has flagged this address as
phishing/scam. Do not send funds to this address.
</div>
</div>
<div <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2" class="mb-2 border border-border border-dashed p-2"
@@ -845,6 +882,24 @@
</div> </div>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Network</h3>
<p class="text-xs text-muted mb-1">
Select the Ethereum network. Switching networks will
update the RPC and Blockscout endpoints to their
defaults.
</p>
<div class="text-xs flex items-center gap-1">
<select
id="settings-network"
class="border border-border p-1 bg-bg text-fg text-xs cursor-pointer"
>
<option value="mainnet">Ethereum Mainnet</option>
<option value="sepolia">Sepolia Testnet</option>
</select>
</div>
</div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Ethereum RPC</h3> <h3 class="font-bold mb-1">Ethereum RPC</h3>
<p class="text-xs text-muted mb-1"> <p class="text-xs text-muted mb-1">
@@ -947,6 +1002,64 @@
</p> </p>
<div id="settings-denied-sites"></div> <div id="settings-denied-sites"></div>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">About</h3>
<p class="text-xs mb-2">
<a
href="https://git.eeqj.de/sneak/AutistMask"
class="underline decoration-dashed"
target="_blank"
rel="noopener noreferrer"
>AutistMask</a
>
— Minimal Ethereum wallet browser extension.
</p>
<div class="text-xs">
<div class="mb-1">
<span class="text-muted">License:</span>
<span id="about-license"></span>
</div>
<div class="mb-1">
<span class="text-muted">Author:</span>
<span id="about-author"></span>
</div>
<div class="mb-1">
<span class="text-muted">Version:</span>
<span
id="about-version"
class="cursor-pointer select-none"
></span>
</div>
<div class="mb-1">
<span class="text-muted">Release date:</span>
<span id="about-release-date"></span>
</div>
<div>
<span class="text-muted">Commit:</span>
<a
id="about-commit-link"
class="underline decoration-dashed"
target="_blank"
rel="noopener noreferrer"
></a>
</div>
</div>
</div>
<div
id="settings-debug-well"
class="bg-well p-3 mx-1 mb-3"
style="display: none"
>
<h3 class="font-bold mb-1">Debug</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer"
>
<input type="checkbox" id="settings-debug-mode" />
Enable debug mode
</label>
</div>
</div> </div>
<!-- ============ DELETE WALLET CONFIRM ============ --> <!-- ============ DELETE WALLET CONFIRM ============ -->
@@ -1064,93 +1177,149 @@
<h2 id="tx-detail-heading" class="font-bold mb-2"> <h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction Transaction
</h2> </h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div> <!-- ── Identity ── -->
<div id="tx-detail-type" class="text-xs font-bold"></div> <div class="bg-well p-3 mx-1 mb-3">
</div> <div class="mb-2">
<div class="mb-4"> <div class="text-xs text-muted mb-1">
<div class="text-xs text-muted mb-1">Status</div> Transaction hash
<div id="tx-detail-status" class="text-xs"></div> </div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="tx-detail-value" class="text-xs"></div>
</div>
<div class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Native quantity</div>
<div id="tx-detail-native" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">From</div>
<div id="tx-detail-from" class="text-xs break-all"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-token-contract-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Token contract</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div
id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div <div
id="tx-detail-calldata-action" id="tx-detail-hash"
class="text-xs font-bold mb-2" class="text-xs break-all"
></div> ></div>
</div>
<div id="tx-detail-type-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div <div
id="tx-detail-calldata-details" id="tx-detail-type"
class="text-xs" class="text-xs font-bold"
></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">From</div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
</div>
<!-- ── Timing ── -->
<div class="bg-well p-3 mx-1 mb-3">
<div class="mb-2">
<div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></div>
</div>
<div id="tx-detail-block-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">Block</div>
<div id="tx-detail-block" class="text-xs"></div>
</div>
</div>
<!-- ── Value ── -->
<div class="bg-well p-3 mx-1 mb-3">
<div class="mb-2">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="tx-detail-value" class="text-xs"></div>
</div>
<div class="mb-2 hidden">
<div class="text-xs text-muted mb-1">
Native quantity
</div>
<div id="tx-detail-native" class="text-xs"></div>
</div>
<div
id="tx-detail-token-contract-section"
class="mb-2 hidden"
>
<div class="text-xs text-muted mb-1">
Token contract
</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
></div> ></div>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div> <!-- ── Decoded details ── -->
<div id="tx-detail-hash" class="text-xs break-all"></div> <div id="tx-detail-calldata-section" class="hidden">
<div class="bg-well p-3 mx-1 mb-3">
<div id="tx-detail-calldata-well" class="mb-2">
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
</div>
</div> </div>
<div id="tx-detail-block-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Block</div> <!-- ── Network details ── -->
<div id="tx-detail-block" class="text-xs"></div> <div id="tx-detail-network-section" class="hidden">
<div class="bg-well p-3 mx-1 mb-3">
<div id="tx-detail-nonce-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">Nonce</div>
<div id="tx-detail-nonce" class="text-xs"></div>
</div>
<div
id="tx-detail-gasprice-section"
class="mb-2 hidden"
>
<div class="text-xs text-muted mb-1">Gas price</div>
<div id="tx-detail-gasprice" class="text-xs"></div>
</div>
<div id="tx-detail-gasused-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">Gas used</div>
<div id="tx-detail-gasused" class="text-xs"></div>
</div>
<div id="tx-detail-fee-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">
Transaction fee
</div>
<div id="tx-detail-fee" class="text-xs"></div>
</div>
</div>
</div> </div>
<div id="tx-detail-nonce-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Nonce</div> <!-- ── Raw data ── -->
<div id="tx-detail-nonce" class="text-xs"></div> <div id="tx-detail-rawdata-section" class="hidden">
</div> <div class="bg-well p-3 mx-1 mb-3">
<div id="tx-detail-fee-section" class="mb-4 hidden"> <div class="mb-2">
<div class="text-xs text-muted mb-1">Transaction fee</div> <div class="text-xs text-muted mb-1">Raw data</div>
<div id="tx-detail-fee" class="text-xs"></div> <div
</div> id="tx-detail-rawdata"
<div id="tx-detail-gasprice-section" class="mb-4 hidden"> class="text-xs break-all font-mono border border-border border-dashed p-2"
<div class="text-xs text-muted mb-1">Gas price</div> ></div>
<div id="tx-detail-gasprice" class="text-xs"></div> </div>
</div> </div>
<div id="tx-detail-gasused-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Gas used</div>
<div id="tx-detail-gasused" class="text-xs"></div>
</div>
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
class="text-xs break-all font-mono border border-border border-dashed p-2"
></div>
</div> </div>
</div> </div>
<!-- ============ TRANSACTION APPROVAL ============ --> <!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden"> <div id="view-approve-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Request</h2> <h2 class="font-bold mb-2">Transaction Request</h2>
<div
id="approve-tx-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. This transaction may steal your funds. Proceed
with extreme caution.
</div>
<p class="mb-2"> <p class="mb-2">
<span id="approve-tx-hostname" class="font-bold"></span> <span id="approve-tx-hostname" class="font-bold"></span>
wants to send a transaction. wants to send a transaction.
@@ -1217,6 +1386,14 @@
<!-- ============ SIGNATURE APPROVAL ============ --> <!-- ============ SIGNATURE APPROVAL ============ -->
<div id="view-approve-sign" class="view hidden"> <div id="view-approve-sign" class="view hidden">
<h2 class="font-bold mb-2">Signature Request</h2> <h2 class="font-bold mb-2">Signature Request</h2>
<div
id="approve-sign-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. Signing this message may authorize theft of your
funds. Proceed with extreme caution.
</div>
<p class="mb-2"> <p class="mb-2">
<span id="approve-sign-hostname" class="font-bold"></span> <span id="approve-sign-hostname" class="font-bold"></span>
wants you to sign a message. wants you to sign a message.
@@ -1286,6 +1463,14 @@
<!-- ============ SITE APPROVAL ============ --> <!-- ============ SITE APPROVAL ============ -->
<div id="view-approve-site" class="view hidden"> <div id="view-approve-site" class="view hidden">
<h2 class="font-bold mb-2">Connection Request</h2> <h2 class="font-bold mb-2">Connection Request</h2>
<div
id="approve-site-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. Connecting your wallet may result in loss of
funds. Proceed with extreme caution.
</div>
<div class="mb-3"> <div class="mb-3">
<p class="mb-2"> <p class="mb-2">
<span id="approve-hostname" class="font-bold"></span> <span id="approve-hostname" class="font-bold"></span>

View File

@@ -1,11 +1,19 @@
// AutistMask popup entry point. // AutistMask popup entry point.
// Loads state, initializes views, triggers first render. // Loads state, initializes views, triggers first render.
const { DEBUG } = require("../shared/constants");
const { state, saveState, loadState } = require("../shared/state"); const { state, saveState, loadState } = require("../shared/state");
const { setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { $, showView } = require("./views/helpers"); const {
$,
showView,
updateDebugBanner,
setRenderMain,
pushCurrentView,
goBack,
clearViewStack,
} = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const home = require("./views/home"); const home = require("./views/home");
@@ -53,15 +61,42 @@ async function doRefreshAndRender() {
const ctx = { const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
showAddWalletView: () => addWallet.show(), showAddWalletView: () => {
showAddressDetail: () => addressDetail.show(), pushCurrentView();
showAddressToken: () => addressToken.show(), addWallet.show();
showAddTokenView: () => addToken.show(), },
showConfirmTx: (txInfo) => confirmTx.show(txInfo), showAddressDetail: () => {
showReceive: () => receive.show(), pushCurrentView();
showTransactionDetail: (tx) => transactionDetail.show(tx), addressDetail.show();
showSettingsView: () => settings.show(), },
showSettingsAddTokenView: () => settingsAddToken.show(), showAddressToken: () => {
pushCurrentView();
addressToken.show();
},
showAddTokenView: () => {
pushCurrentView();
addToken.show();
},
showConfirmTx: (txInfo) => {
pushCurrentView();
confirmTx.show(txInfo);
},
showReceive: () => {
pushCurrentView();
receive.show();
},
showTransactionDetail: (tx) => {
pushCurrentView();
transactionDetail.show(tx);
},
showSettingsView: () => {
pushCurrentView();
settings.show();
},
showSettingsAddTokenView: () => {
pushCurrentView();
settingsAddToken.show();
},
}; };
// Views that can be fully re-rendered from persisted state. // Views that can be fully re-rendered from persisted state.
@@ -167,18 +202,15 @@ function fallbackView() {
} }
async function init() { async function init() {
if (DEBUG) {
const banner = document.createElement("div");
banner.id = "debug-banner";
banner.textContent = "DEBUG / INSECURE";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner);
}
await loadState(); await loadState();
applyTheme(state.theme); applyTheme(state.theme);
// Sync runtime debug flag from persisted state before first render
setRuntimeDebug(state.debugMode);
// Create the debug/testnet banner if needed (uses runtime debug state)
updateDebugBanner();
// Auto-default active address // Auto-default active address
if ( if (
state.activeAddress === null && state.activeAddress === null &&
@@ -208,13 +240,15 @@ async function init() {
.getElementById("view-settings") .getElementById("view-settings")
.classList.contains("hidden") .classList.contains("hidden")
) { ) {
renderWalletList(); goBack();
showView("main");
return; return;
} }
pushCurrentView();
settings.show(); settings.show();
}); });
setRenderMain(renderWalletList);
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);
home.init(ctx); home.init(ctx);

View File

@@ -10,7 +10,7 @@
--color-border: #000000; --color-border: #000000;
--color-border-light: #cccccc; --color-border-light: #cccccc;
--color-hover: #eeeeee; --color-hover: #eeeeee;
--color-well: #f5f5f5; --color-well: #e8e8e8;
--color-danger-well: #fef2f2; --color-danger-well: #fef2f2;
--color-section: #dddddd; --color-section: #dddddd;
} }

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -59,7 +59,12 @@ function init(ctx) {
}); });
await saveState(); await saveState();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
ctx.showAddressDetail(); // Pop the stack (back to address detail) and re-render it
// so the newly added token is visible immediately.
if (state.viewStack.length > 0) {
state.viewStack.pop();
}
require("./addressDetail").show();
} catch (e) { } catch (e) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail); log.errorf("Token lookup failed for", contractAddr, detail);
@@ -69,7 +74,9 @@ function init(ctx) {
} }
}); });
$("btn-add-token-back").addEventListener("click", ctx.showAddressDetail); $("btn-add-token-back").addEventListener("click", () => {
goBack();
});
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash } = require("./helpers"); const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
const { const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
@@ -143,6 +143,7 @@ async function importMnemonic(ctx) {
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
@@ -198,6 +199,7 @@ async function importPrivateKey(ctx) {
}); });
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
@@ -249,6 +251,7 @@ async function importXprvKey(ctx) {
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
@@ -297,12 +300,7 @@ function init(ctx) {
// Back button // Back button
$("btn-add-wallet-back").addEventListener("click", () => { $("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) { goBack();
showView("welcome");
} else {
ctx.renderWalletList();
showView("main");
}
}); });
} }

View File

@@ -8,6 +8,10 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
@@ -28,17 +32,6 @@ const { getSignerForAddress } = require("../../shared/wallet");
let ctx; let ctx;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`;
}
function show() { function show() {
state.selectedToken = null; state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
@@ -56,22 +49,18 @@ function show() {
img.style.imageRendering = "pixelated"; img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%"; img.style.borderRadius = "50%";
blockieEl.appendChild(img); blockieEl.appendChild(img);
$("address-dot").innerHTML = addressDotHtml(addr.address); const addrTitle = addressTitle(addr.address, state.wallets);
$("address-full").dataset.full = addr.address; $("address-line").innerHTML = renderAddressHtml(addr.address, {
$("address-full").textContent = addr.address; title: addrTitle,
const addrLink = etherscanAddressLink(addr.address); ensName: addr.ensName,
$("address-etherscan-link").innerHTML = });
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatUsd(getAddressValueUsd(addr)); const usdTotal = formatUsd(getAddressValueUsd(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;"; $("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
if (addr.ensName) { // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.innerHTML = ensEl.classList.add("hidden");
addressDotHtml(addr.address) + escapeHtml(addr.ensName);
ensEl.classList.remove("hidden");
} else {
ensEl.classList.add("hidden");
}
$("address-balances").innerHTML = balanceLinesForAddress( $("address-balances").innerHTML = balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
@@ -258,18 +247,9 @@ function renderTransactions(txs) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("address-full").addEventListener("click", () => {
const addr = $("address-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-full"));
}
});
$("btn-address-back").addEventListener("click", () => { $("btn-address-back").addEventListener("click", () => {
ctx.renderWalletList(); goBack();
showView("main");
}); });
$("btn-send").addEventListener("click", () => { $("btn-send").addEventListener("click", () => {
@@ -287,6 +267,7 @@ function init(_ctx) {
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView();
showView("send"); showView("send");
}); });
@@ -316,6 +297,7 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => { $("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden"); moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg"); moreBtn.classList.remove("bg-fg", "text-bg");
pushCurrentView();
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon"); const blockieEl = $("export-privkey-jazzicon");
@@ -329,9 +311,9 @@ function init(_ctx) {
blockieEl.appendChild(bImg); blockieEl.appendChild(bImg);
$("export-privkey-title").textContent = $("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1); wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
$("export-privkey-dot").innerHTML = addressDotHtml(addr.address); const exportAddrContainer = $("export-privkey-dot").parentElement;
$("export-privkey-address").textContent = addr.address; exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
$("export-privkey-address").dataset.full = addr.address; attachCopyHandlers(exportAddrContainer);
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
$("export-privkey-flash").textContent = ""; $("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden"; $("export-privkey-flash").style.visibility = "hidden";
@@ -385,19 +367,10 @@ function init(_ctx) {
} }
}); });
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-address"));
}
});
$("btn-export-privkey-back").addEventListener("click", () => { $("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = ""; $("export-privkey-value").textContent = "";
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
show(); goBack();
}); });
} }

View File

@@ -11,6 +11,10 @@ const {
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
balanceLine, balanceLine,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
@@ -34,17 +38,6 @@ const makeBlockie = require("ethereum-blockies-base64");
let ctx; let ctx;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`;
}
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -148,15 +141,16 @@ function show() {
blockieEl.appendChild(img); blockieEl.appendChild(img);
// Address line // Address line
$("address-token-dot").innerHTML = addressDotHtml(addr.address); const addrTitle = addressTitle(addr.address, state.wallets);
$("address-token-full").dataset.full = addr.address; $("address-token-line").innerHTML = renderAddressHtml(addr.address, {
$("address-token-full").textContent = addr.address; title: addrTitle,
const addrLink = etherscanAddressLink(addr.address); ensName: addr.ensName,
$("address-token-etherscan-link").innerHTML = });
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; $("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
// USD total for this token only // USD total for this token only
const usdVal = price ? amount * price : 0; const usdVal = price ? amount * price : null;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;"; $("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
@@ -193,15 +187,9 @@ function show() {
? knownToken.decimals ? knownToken.decimals
: null; : null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null; const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
const projectUrl = knownToken && knownToken.url ? knownToken.url : null; const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`; let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml += infoHtml += `<div class="mb-2">${renderAddressHtml(tokenId)}</div>`;
`<div class="flex items-center mb-2">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
if (tokenName) if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol) if (tokenSymbol)
@@ -213,6 +201,7 @@ function show() {
if (projectUrl) if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml; contractInfo.innerHTML = infoHtml;
attachCopyHandlers(contractInfo);
contractInfo.classList.remove("hidden"); contractInfo.classList.remove("hidden");
} else { } else {
contractInfo.innerHTML = ""; contractInfo.innerHTML = "";
@@ -334,15 +323,6 @@ function renderTransactions(txs) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("address-token-full").addEventListener("click", () => {
const addr = $("address-token-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-token-full"));
}
});
$("address-token-contract-info").addEventListener("click", (e) => { $("address-token-contract-info").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]"); const copyEl = e.target.closest("[data-copy]");
if (copyEl) { if (copyEl) {
@@ -353,7 +333,7 @@ function init(_ctx) {
}); });
$("btn-address-token-back").addEventListener("click", () => { $("btn-address-token-back").addEventListener("click", () => {
ctx.showAddressDetail(); goBack();
}); });
$("btn-address-token-send").addEventListener("click", () => { $("btn-address-token-send").addEventListener("click", () => {
@@ -380,28 +360,14 @@ function init(_ctx) {
$("send-token").classList.add("hidden"); $("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`; let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`;
if (tokenId !== "ETH") { if (tokenId !== "ETH") {
const dot = addressDotHtml(tokenId); staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
const link = `https://etherscan.io/token/${tokenId}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
staticHtml +=
`<div class="flex items-center text-xs">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
extLink +
`</div>`;
} }
$("send-token-static").innerHTML = staticHtml; $("send-token-static").innerHTML = staticHtml;
$("send-token-static").classList.remove("hidden"); $("send-token-static").classList.remove("hidden");
// Attach copy handler for the contract address attachCopyHandlers($("send-token-static"));
const copyEl = $("send-token-static").querySelector("[data-copy]");
if (copyEl) {
copyEl.addEventListener("click", () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
});
}
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView();
showView("send"); showView("send");
}); });

View File

@@ -1,44 +1,37 @@
const { const {
$, $,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
showView, showView,
showError, showError,
hideError, hideError,
renderAddressHtml,
attachCopyHandlers,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); 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 { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); 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 txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
function approvalAddressHtml(address) { function approvalAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
let html = ""; return renderAddressHtml(address, { title });
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="break-all">${escapeHtml(address)}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
return html;
} }
function formatTxValue(val) { function formatTxValue(val) {
@@ -53,10 +46,6 @@ function tokenLabel(address) {
return t ? t.symbol : null; return t ? t.symbol : null;
} }
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) { function decodeCalldata(data, toAddress) {
@@ -155,7 +144,26 @@ function decodeCalldata(data, toAddress) {
return null; return null;
} }
function showPhishingWarning(elementId, isPhishing) {
const el = $(elementId);
if (!el) return;
// The background script performs the authoritative phishing domain check
// and passes the result via the isPhishingDomain flag.
if (isPhishing) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
function showTxApproval(details) { function showTxApproval(details) {
showPhishingWarning(
"approve-tx-phishing-warning",
details.isPhishingDomain,
);
pendingTxParams = details.txParams;
const toAddr = details.txParams.to; const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null; const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0"); const ethValue = formatEther(details.txParams.value || "0");
@@ -218,17 +226,19 @@ function showTxApproval(details) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`; toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
} }
toHtml += approvalAddressHtml(toAddr); toHtml += approvalAddressHtml(toAddr);
if (symbol) {
const link = etherscanTokenLink(toAddr);
toHtml = toHtml.replace("</div>", "") + ""; // approvalAddressHtml already has etherscan link
}
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)"); $("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
} }
const ethValueFormatted = formatTxValue(
formatEther(details.txParams.value || "0"),
);
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
formatTxValue(formatEther(details.txParams.value || "0")) + " ETH"; ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
// Decode calldata (reuse decoded from above) // Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded"); const decodedEl = $("approve-tx-decoded");
@@ -243,12 +253,9 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
const tLink = etherscanTokenLink(d.address);
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
detailsHtml += approvalAddressHtml(d.address);
} else {
detailsHtml += approvalAddressHtml(d.address);
} }
detailsHtml += approvalAddressHtml(d.address);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
@@ -272,6 +279,7 @@ function showTxApproval(details) {
hideError("approve-tx-error"); hideError("approve-tx-error");
showView("approve-tx"); showView("approve-tx");
attachCopyHandlers("view-approve-tx");
} }
function decodeHexMessage(hex) { function decodeHexMessage(hex) {
@@ -323,7 +331,13 @@ function formatTypedDataHtml(jsonStr) {
} }
function showSignApproval(details) { function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.isPhishingDomain,
);
const sp = details.signParams; const sp = details.signParams;
pendingSignParams = sp;
$("approve-sign-hostname").textContent = details.hostname; $("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml(sp.from); $("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
@@ -364,6 +378,7 @@ function showSignApproval(details) {
$("btn-approve-sign").classList.remove("text-muted"); $("btn-approve-sign").classList.remove("text-muted");
showView("approve-sign"); showView("approve-sign");
attachCopyHandlers("view-approve-sign");
} }
function show(id) { function show(id) {
@@ -382,16 +397,52 @@ function show(id) {
showSignApproval(details); showSignApproval(details);
return; return;
} }
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname; $("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml( $("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress, state.activeAddress,
); );
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice; $("approve-remember").checked = state.rememberSiteChoice;
}); });
} }
let approvalId = null; let approvalId = null;
let pendingTxDetails = 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) { function init(ctx) {
$("approve-remember").addEventListener("change", async () => { $("approve-remember").addEventListener("change", async () => {
@@ -421,34 +472,86 @@ function init(ctx) {
window.close(); window.close();
}); });
$("btn-approve-tx").addEventListener("click", () => { $("btn-approve-tx").addEventListener("click", async () => {
const password = $("approve-tx-password").value; let password = $("approve-tx-password").value;
if (!password) { if (!password) {
showError("approve-tx-error", "Please enter your password."); showError("approve-tx-error", "Please enter your password.");
return; return;
} }
hideError("approve-tx-error"); hideError("approve-tx-error");
$("btn-approve-tx").disabled = true; setTxButtonBusy(true);
$("btn-approve-tx").classList.add("text-muted");
runtime.sendMessage( const active = findActiveWallet();
{ if (!active) {
type: "AUTISTMASK_TX_RESPONSE", password = null;
id: approvalId, showError(
approved: true, "approve-tx-error",
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage "No wallet was found for the active address.",
password: password, );
}, setTxButtonBusy(false);
(response) => { return;
if (response && response.txHash) { }
txStatus.showWait(pendingTxDetails, response.txHash);
} else { // Decrypt here, in the popup. The password must never cross the
const msg = // extension messaging boundary; only the signed transaction does.
(response && response.error) || "Transaction failed."; let decryptedSecret;
txStatus.showError(pendingTxDetails, null, msg); 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", () => { $("btn-reject-tx").addEventListener("click", () => {
@@ -460,36 +563,93 @@ function init(ctx) {
window.close(); window.close();
}); });
$("btn-approve-sign").addEventListener("click", () => { $("btn-approve-sign").addEventListener("click", async () => {
const password = $("approve-sign-password").value; let password = $("approve-sign-password").value;
if (!password) { if (!password) {
showError("approve-sign-error", "Please enter your password."); showError("approve-sign-error", "Please enter your password.");
return; return;
} }
hideError("approve-sign-error"); hideError("approve-sign-error");
$("btn-approve-sign").disabled = true; setSignButtonBusy(true);
$("btn-approve-sign").classList.add("text-muted");
runtime.sendMessage( const active = findActiveWallet();
{ if (!active) {
type: "AUTISTMASK_SIGN_RESPONSE", password = null;
id: approvalId, showError(
approved: true, "approve-sign-error",
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage "No wallet was found for the active address.",
password: password, );
}, setSignButtonBusy(false);
(response) => { return;
if (response && response.signature) { }
window.close();
} else { // Decrypt here, in the popup. The password must never cross the
const msg = // extension messaging boundary; only the signature does.
(response && response.error) || "Signing failed."; let decryptedSecret;
showError("approve-sign-error", msg); try {
$("btn-approve-sign").disabled = false; decryptedSecret = await decryptWithPassword(
$("btn-approve-sign").classList.remove("text-muted"); 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", () => { $("btn-reject-sign").addEventListener("click", () => {

View File

@@ -17,27 +17,25 @@ const {
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
addressTitle, addressTitle,
addressDotHtml,
escapeHtml, escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const {
const { ERC20_ABI } = require("../../shared/constants"); getLocalWarnings,
getFullWarnings,
} = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
let pendingTx = null; let pendingTx = null;
function restore() { function restore() {
@@ -47,14 +45,6 @@ function restore() {
} }
} }
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`;
}
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
@@ -62,22 +52,10 @@ function blockieHtml(address) {
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
const dot = addressDotHtml(address); return (
const link = etherscanAddressLink(address); `<div class="mb-1">${blockie}</div>` +
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; renderAddressHtml(address, { title, ensName })
let html = `<div class="mb-1">${blockie}</div>`; );
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
html +=
`<div class="flex items-center">${title || ensName ? "" : dot}` +
`<span class="break-all">${escapeHtml(address)}</span>` +
extLink +
`</div>`;
return html;
} }
function valueWithUsd(text, usdAmount) { function valueWithUsd(text, usdAmount) {
@@ -104,23 +82,12 @@ function show(txInfo) {
// Token contract section (ERC-20 only) // Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section"); const tokenSection = $("confirm-token-section");
if (isErc20) { if (isErc20) {
const dot = addressDotHtml(txInfo.token); $("confirm-token-contract").innerHTML = renderAddressHtml(
const link = etherscanTokenLink(txInfo.token); txInfo.token,
$("confirm-token-contract").innerHTML = {},
`<div class="flex items-center">${dot}` + );
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
tokenSection.classList.remove("hidden"); tokenSection.classList.remove("hidden");
// Attach click-to-copy on the contract address attachCopyHandlers(tokenSection);
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
};
}
} else { } else {
tokenSection.classList.add("hidden"); tokenSection.classList.add("hidden");
} }
@@ -167,23 +134,17 @@ function show(txInfo) {
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd); $("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
} }
// Check for warnings // Check for warnings (synchronous local checks)
const warnings = []; const localWarnings = getLocalWarnings(txInfo.to, {
if (isScamAddress(txInfo.to)) { fromAddress: txInfo.from,
warnings.push( });
"This address is on a known scam/fraud list. Do not send funds to this address.",
);
}
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
warnings.push("You are sending to your own address.");
}
const warningsEl = $("confirm-warnings"); const warningsEl = $("confirm-warnings");
if (warnings.length > 0) { if (localWarnings.length > 0) {
warningsEl.innerHTML = warnings warningsEl.innerHTML = localWarnings
.map( .map(
(w) => (w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`, `<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
) )
.join(""); .join("");
warningsEl.style.visibility = "visible"; warningsEl.style.visibility = "visible";
@@ -246,9 +207,18 @@ function show(txInfo) {
$("confirm-fee-amount").textContent = "Estimating..."; $("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo }; state.viewData = { pendingTx: txInfo };
showView("confirm-tx"); showView("confirm-tx");
attachCopyHandlers("view-confirm-tx");
// Reset recipient warning to hidden (space always reserved, no layout shift) // Reset async warnings to hidden (space always reserved, no layout shift)
$("confirm-recipient-warning").style.visibility = "hidden"; $("confirm-recipient-warning").style.visibility = "hidden";
$("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden";
$("confirm-etherscan-warning").style.visibility = "hidden";
// Show burn warning via reserved element (in addition to inline warning)
if (isBurnAddress(txInfo.to)) {
$("confirm-burn-warning").style.visibility = "visible";
}
estimateGas(txInfo); estimateGas(txInfo);
checkRecipientHistory(txInfo); checkRecipientHistory(txInfo);
@@ -295,19 +265,21 @@ async function estimateGas(txInfo) {
} }
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
const el = $("confirm-recipient-warning");
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
// Skip warning for contract addresses — they may legitimately const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
// have zero outgoing transactions (getTransactionCount returns fromAddress: txInfo.from,
// the nonce, i.e. sent-tx count only). });
const code = await provider.getCode(txInfo.to); for (const w of asyncWarnings) {
if (code && code !== "0x") { if (w.type === "contract") {
return; $("confirm-contract-warning").style.visibility = "visible";
} }
const txCount = await provider.getTransactionCount(txInfo.to); if (w.type === "new-address") {
if (txCount === 0) { $("confirm-recipient-warning").style.visibility = "visible";
el.style.visibility = "visible"; }
if (w.type === "etherscan-phishing") {
$("confirm-etherscan-warning").style.visibility = "visible";
}
} }
} catch (e) { } catch (e) {
log.errorf("recipient history check failed:", e.message); log.errorf("recipient history check failed:", e.message);
@@ -384,7 +356,7 @@ function init(ctx) {
}); });
$("btn-confirm-back").addEventListener("click", () => { $("btn-confirm-back").addEventListener("click", () => {
showView("send"); goBack();
}); });
} }

View File

@@ -1,6 +1,10 @@
const { $, showView, showFlash } = require("./helpers"); const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let ctx = null; let ctx = null;
@@ -21,7 +25,7 @@ function init(_ctx) {
$("btn-delete-wallet-back").addEventListener("click", () => { $("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null; deleteWalletIndex = null;
ctx.showSettingsView(); goBack();
}); });
$("btn-delete-wallet-confirm").addEventListener("click", async () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
@@ -58,36 +62,32 @@ function init(_ctx) {
return; return;
} }
// Collect addresses to clean up from allowedSites/deniedSites // Remove the wallet and repair selection, permissions and hasWallet
const addresses = (wallet.addresses || []).map((a) => a.address); const { activeAddressChanged } = removeWalletFromState(
state,
// Remove wallet walletIdx,
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];
}
deleteWalletIndex = null; deleteWalletIndex = null;
if (state.wallets.length === 0) { if (!state.hasWallet) {
// No wallets left — reset selection and show welcome clearViewStack();
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
await saveState(); await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome"); showView("welcome");
} else { } 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(); 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).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList(); ctx.renderWalletList();
ctx.showSettingsView(); const settings = require("./settings");
settings.show();
showFlash("Wallet deleted."); showFlash("Wallet deleted.");
} }
}); });

View File

@@ -1,12 +1,12 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
const { DEBUG } = require("../../shared/constants"); const { isDebug } = require("../../shared/log");
const { const {
formatUsd, formatUsd,
getPrice, getPrice,
getAddressValueUsd, getAddressValueUsd,
} = require("../../shared/prices"); } = require("../../shared/prices");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match. // update the view-navigation documentation in README.md to match.
@@ -59,14 +59,77 @@ function showView(name) {
clearFlash(); clearFlash();
state.currentView = name; state.currentView = name;
saveState(); saveState();
if (DEBUG) { updateDebugBanner(name);
const banner = document.getElementById("debug-banner"); }
if (banner) {
banner.textContent = "DEBUG / INSECURE (" + name + ")"; // Create or update the debug/insecure warning banner.
// Called on every view switch and after the settings debug toggle changes.
// The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
function updateDebugBanner(viewName) {
const debug = isDebug();
const net = currentNetwork();
const show = debug || net.isTestnet;
let banner = document.getElementById("debug-banner");
if (show) {
if (!banner) {
banner = document.createElement("div");
banner.id = "debug-banner";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner);
} }
const suffix = viewName ? " (" + viewName + ")" : "";
if (debug && net.isTestnet) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) {
banner.textContent = "[TESTNET]" + suffix;
} else {
banner.textContent = "DEBUG / INSECURE" + suffix;
}
} else if (banner) {
banner.remove();
} }
} }
// Callback to re-render the main/home view when navigating back to it.
// Set once by index.js via setRenderMain().
let _renderMain = null;
function setRenderMain(fn) {
_renderMain = fn;
}
// Push the current view onto the navigation stack so goBack() can
// return to it. Call this before any forward navigation.
function pushCurrentView() {
if (state.currentView) {
state.viewStack.push(state.currentView);
}
}
// Pop the navigation stack and show the previous view. If the stack
// is empty, fall back to the main (home) view.
function goBack() {
let target;
if (state.viewStack.length > 0) {
target = state.viewStack.pop();
} else {
target = "main";
}
if (target === "main" && _renderMain) {
_renderMain();
}
showView(target);
}
// Clear the entire navigation stack (used when resetting to root,
// e.g. after adding or deleting a wallet).
function clearViewStack() {
state.viewStack = [];
}
let flashTimer = null; let flashTimer = null;
function clearFlash() { function clearFlash() {
@@ -208,21 +271,9 @@ function addressTitle(address, wallets) {
// Render an address with color dot, optional ENS name, optional title, // Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above // and optional truncation. Title and ENS are shown as bold labels above
// the full address. // the full address.
// Delegates to renderAddressHtml for consistent output.
function formatAddressHtml(address, ensName, maxLen, title) { function formatAddressHtml(address, ensName, maxLen, title) {
const dot = addressDotHtml(address); return renderAddressHtml(address, { title, ensName, maxLen });
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
if (title || ensName) {
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
html += `<div class="break-all">${escapeHtml(displayAddr)}</div>`;
return html;
}
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
} }
function isoDate(timestamp) { function isoDate(timestamp) {
@@ -281,6 +332,91 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago"; return years + " year" + (years !== 1 ? "s" : "") + " ago";
} }
// Shared external-link icon SVG used across all views.
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressUrl(address) {
return `${currentNetwork().explorerUrl}/address/${address}`;
}
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center">${EXT_ICON}</a>`
);
}
// Render a copyable text span with dashed underline affordance.
// The caller must attach click handlers via attachCopyHandlers() or
// manually wire up [data-copy] elements after inserting the HTML.
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}
// Attach click-to-copy handlers to all [data-copy] elements within
// a container. Safe to call multiple times on the same container.
function attachCopyHandlers(container) {
const root =
typeof container === "string"
? document.getElementById(container)
: container;
if (!root) return;
root.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
// Unified address rendering.
//
// Produces consistent HTML for any Ethereum address:
// • Color dot
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
// • Optional ENS name shown bold above address
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
// • Etherscan external link icon
//
// Options object:
// title — wallet title string (from addressTitle)
// ensName — ENS name string
// maxLen — if set, truncate address display (min 32 chars enforced)
// noLink — if true, omit etherscan link
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
if (title || ensName) {
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
}
return html;
}
function flashCopyFeedback(el) { function flashCopyFeedback(el) {
if (!el) return; if (!el) return;
el.classList.remove("copy-flash-fade"); el.classList.remove("copy-flash-fade");
@@ -299,6 +435,11 @@ module.exports = {
showError, showError,
hideError, hideError,
showView, showView,
updateDebugBanner,
setRenderMain,
pushCurrentView,
goBack,
clearViewStack,
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
balanceLine, balanceLine,
@@ -308,6 +449,12 @@ module.exports = {
escapeHtml, escapeHtml,
addressTitle, addressTitle,
formatAddressHtml, formatAddressHtml,
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml,
EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,
timeAgo, timeAgo,

View File

@@ -10,6 +10,9 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const { state, saveState, currentAddress } = require("../../shared/state");
const { const {
@@ -69,28 +72,12 @@ function renderTotalValue() {
} }
} }
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function renderActiveAddress() { function renderActiveAddress() {
const el = $("active-address-display"); const el = $("active-address-display");
if (!el) return; if (!el) return;
if (state.activeAddress) { if (state.activeAddress) {
const addr = state.activeAddress; el.innerHTML = renderAddressHtml(state.activeAddress);
const dot = addressDotHtml(addr); attachCopyHandlers(el);
const link = `https://etherscan.io/address/${addr}`;
el.innerHTML =
`<span class="underline decoration-dashed cursor-pointer" id="active-addr-copy">${dot}${escapeHtml(addr)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
$("active-addr-copy").addEventListener("click", (e) => {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
});
} else { } else {
el.textContent = ""; el.textContent = "";
} }
@@ -395,6 +382,7 @@ function init(ctx) {
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView();
showView("send"); showView("send");
}); });

View File

@@ -5,17 +5,12 @@ const {
flashCopyFeedback, flashCopyFeedback,
formatAddressHtml, formatAddressHtml,
addressTitle, addressTitle,
attachCopyHandlers,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress, currentNetwork } = require("../../shared/state");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function show() { function show() {
const addr = currentAddress(); const addr = currentAddress();
const address = addr ? addr.address : ""; const address = addr ? addr.address : "";
@@ -25,10 +20,8 @@ function show() {
? formatAddressHtml(address, ensName, null, title) ? formatAddressHtml(address, ensName, null, title)
: ""; : "";
$("receive-address-block").dataset.full = address; $("receive-address-block").dataset.full = address;
const link = address ? `https://etherscan.io/address/${address}` : ""; // Etherscan link is now included in formatAddressHtml via renderAddressHtml
$("receive-etherscan-link").innerHTML = link $("receive-etherscan-link").innerHTML = "";
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
: "";
if (address) { if (address) {
QRCode.toCanvas($("receive-qr"), address, { QRCode.toCanvas($("receive-qr"), address, {
width: 200, width: 200,
@@ -52,25 +45,19 @@ function show() {
warningEl.textContent = warningEl.textContent =
"This is an ERC-20 token. Only send " + "This is an ERC-20 token. Only send " +
symbol + symbol +
" on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss."; " on " +
currentNetwork().name +
" to this address. Sending tokens on other networks will result in permanent loss.";
warningEl.style.visibility = "visible"; warningEl.style.visibility = "visible";
} else { } else {
warningEl.textContent = ""; warningEl.textContent = "";
warningEl.style.visibility = "hidden"; warningEl.style.visibility = "hidden";
} }
showView("receive"); showView("receive");
attachCopyHandlers("view-receive");
} }
function init(ctx) { function init(ctx) {
$("receive-address-block").addEventListener("click", (e) => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}
});
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full; const addr = $("receive-address-block").dataset.full;
if (addr) { if (addr) {
@@ -81,11 +68,7 @@ function init(ctx) {
}); });
$("btn-receive-back").addEventListener("click", () => { $("btn-receive-back").addEventListener("click", () => {
if (state.selectedToken) { goBack();
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}); });
} }

View File

@@ -3,9 +3,11 @@
const { const {
$, $,
showFlash, showFlash,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
@@ -113,13 +115,6 @@ function updateToValidation() {
} }
} }
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function isSpoofedToken(t) { function isSpoofedToken(t) {
const upper = (t.symbol || "").toUpperCase(); const upper = (t.symbol || "").toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) return false; if (!KNOWN_SYMBOLS.has(upper)) return false;
@@ -148,24 +143,12 @@ function renderSendTokenSelect(addr) {
function updateSendBalance() { function updateSendBalance() {
const addr = currentAddress(); const addr = currentAddress();
if (!addr) return; if (!addr) return;
const dot = addressDotHtml(addr.address);
const link = `https://etherscan.io/address/${addr.address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(addr.address, state.wallets); const title = addressTitle(addr.address, state.wallets);
let fromHtml = ""; $("send-from").innerHTML = renderAddressHtml(addr.address, {
if (title) { title,
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`; ensName: addr.ensName,
if (addr.ensName) { });
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`; attachCopyHandlers($("send-from"));
}
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else if (addr.ensName) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else {
fromHtml += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(addr.address)}</span>${extLink}</div>`;
}
$("send-from").innerHTML = fromHtml;
const token = state.selectedToken || $("send-token").value; const token = state.selectedToken || $("send-token").value;
if (token === "ETH") { if (token === "ETH") {
$("send-balance").textContent = $("send-balance").textContent =
@@ -268,11 +251,7 @@ function init(_ctx) {
$("btn-send-back").addEventListener("click", () => { $("btn-send-back").addEventListener("click", () => {
$("send-token").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
if (state.selectedToken) { goBack();
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}); });
} }

View File

@@ -1,13 +1,34 @@
const { $, showView, showFlash, escapeHtml } = require("./helpers"); const {
$,
showView,
updateDebugBanner,
showFlash,
escapeHtml,
flashCopyFeedback,
goBack,
pushCurrentView,
} = require("./helpers");
const { applyTheme } = require("../theme"); const { applyTheme } = require("../theme");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants"); const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { log, debugFetch } = require("../../shared/log"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_DATE,
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
let versionClickCount = 0;
let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) { function renderSiteList(containerId, siteMap, stateKey) {
const container = $(containerId); const container = $(containerId);
const hostnames = [...new Set(Object.values(siteMap).flat())]; const hostnames = [...new Set(Object.values(siteMap).flat())];
@@ -85,6 +106,7 @@ function renderWalletListSettings() {
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => { container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10); const idx = parseInt(btn.dataset.idx, 10);
pushCurrentView();
deleteWallet.show(idx); deleteWallet.show(idx);
}); });
}); });
@@ -125,10 +147,36 @@ function renderWalletListSettings() {
function show() { function show() {
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
const networkSelect = $("settings-network");
if (networkSelect) {
networkSelect.value = state.networkId;
}
renderTrackedTokens(); renderTrackedTokens();
renderSiteLists(); renderSiteLists();
renderWalletListSettings(); renderWalletListSettings();
// Populate About well
$("about-license").textContent = BUILD_LICENSE;
// Show only the name part of the author field (strip email)
const authorName = BUILD_AUTHOR.replace(/\s*<[^>]+>/, "");
$("about-author").textContent = authorName;
$("about-version").textContent = BUILD_VERSION;
$("about-release-date").textContent = BUILD_DATE;
$("about-commit-link").textContent = BUILD_COMMIT;
$("about-commit-link").href = GITEA_COMMIT_URL;
// Reset version click counter each time settings opens
versionClickCount = 0;
// Show debug well if debug mode is already enabled
const debugWell = $("settings-debug-well");
if (state.debugMode) {
debugWell.style.display = "";
} else {
debugWell.style.display = "none";
}
$("settings-debug-mode").checked = state.debugMode;
showView("settings"); showView("settings");
} }
@@ -168,9 +216,12 @@ function init(ctx) {
showFlash("Endpoint returned error: " + json.error.message); showFlash("Endpoint returned error: " + json.error.message);
return; return;
} }
if (json.result !== ETHEREUM_MAINNET_CHAIN_ID) { const net = currentNetwork();
if (json.result !== net.chainId) {
showFlash( showFlash(
"Wrong network (expected mainnet, got chain " + "Wrong network (expected " +
net.name +
", got chain " +
json.result + json.result +
").", ").",
); );
@@ -209,6 +260,17 @@ function init(ctx) {
showFlash("Saved."); showFlash("Saved.");
}); });
const networkSelect = $("settings-network");
if (networkSelect) {
networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value;
const net = await onChainSwitch(newId);
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + ".");
});
}
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens; $("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
$("settings-show-zero-balances").addEventListener("change", async () => { $("settings-show-zero-balances").addEventListener("change", async () => {
state.showZeroBalanceTokens = $("settings-show-zero-balances").checked; state.showZeroBalanceTokens = $("settings-show-zero-balances").checked;
@@ -262,9 +324,68 @@ function init(ctx) {
ctx.showSettingsAddTokenView, ctx.showSettingsAddTokenView,
); );
// Bright saturated colors for easter egg flashes (clicks 610)
const easterEggColors = [
"#ff0055", // hot pink
"#00cc44", // vivid green
"#3366ff", // electric blue
"#ff9900", // bright orange
"#aa00ff", // vivid purple
];
// Easter egg: click version 10 times to reveal the debug well.
// Each click does a copy-flash animation. After 5 clicks, each
// additional click flashes a different bright saturated color.
$("about-version").addEventListener("click", () => {
versionClickCount++;
clearTimeout(versionClickTimer);
// Reset counter if user stops clicking for 3 seconds
versionClickTimer = setTimeout(() => {
versionClickCount = 0;
}, 3000);
const el = $("about-version");
if (versionClickCount > 5) {
// Colored flash for clicks 610
const colorIdx = versionClickCount - 6;
const color = easterEggColors[colorIdx % easterEggColors.length];
el.classList.remove("copy-flash-fade");
el.style.backgroundColor = color;
el.style.color = "#ffffff";
setTimeout(() => {
el.style.backgroundColor = "";
el.style.color = "";
el.classList.add("copy-flash-fade");
setTimeout(() => {
el.classList.remove("copy-flash-fade");
}, 275);
}, 75);
} else {
// Standard copy-flash for clicks 15
flashCopyFeedback(el);
}
if (versionClickCount >= 10) {
versionClickCount = 0;
clearTimeout(versionClickTimer);
$("settings-debug-well").style.display = "";
}
});
// Debug mode toggle — update runtime flag, persist, and re-render banner
$("settings-debug-mode").addEventListener("change", async () => {
state.debugMode = $("settings-debug-mode").checked;
setRuntimeDebug(state.debugMode);
await saveState();
updateDebugBanner(state.currentView);
});
// Sync runtime debug flag on init
setRuntimeDebug(state.debugMode);
$("btn-settings-back").addEventListener("click", () => { $("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList(); goBack();
showView("main");
}); });
} }

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -84,7 +84,7 @@ function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("btn-settings-addtoken-back").addEventListener("click", () => { $("btn-settings-addtoken-back").addEventListener("click", () => {
ctx.showSettingsView(); goBack();
}); });
$("btn-settings-addtoken-select").addEventListener("click", async () => { $("btn-settings-addtoken-select").addEventListener("click", async () => {

View File

@@ -6,25 +6,23 @@ const {
showView, showView,
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
addressDotHtml,
addressTitle, addressTitle,
addressDotHtml,
escapeHtml, escapeHtml,
isoDate, isoDate,
timeAgo, timeAgo,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
let ctx; let ctx;
/** /**
@@ -46,56 +44,21 @@ function getTransactionType(tx) {
return "Native ETH Transfer"; return "Native ETH Transfer";
} }
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function etherscanLinkHtml(url) { function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
return ( return (
`<a href="${url}" target="_blank" rel="noopener" ` + `<div class="mb-1">${blockie}</div>` +
`class="inline-flex items-center"` + renderAddressHtml(address, { title, ensName })
`>${EXT_ICON}</a>`
); );
} }
function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = etherscanLinkHtml(link);
let html = `<div class="mb-1">${blockie}</div>`;
if (title) {
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
}
if (ensName) {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") +
`</div>` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
} else {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
}
return html;
}
function txHashHtml(hash) { function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`; const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const extLink = etherscanLinkHtml(link); const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink; return copyableHtml(hash, "break-all") + extLink;
} }
@@ -172,7 +135,7 @@ function render() {
if (tokenContractSection && tokenContractEl) { if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) { if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress); const dot = addressDotHtml(tx.contractAddress);
const link = `https://etherscan.io/token/${tx.contractAddress}`; const link = `${currentNetwork().explorerUrl}/token/${tx.contractAddress}`;
tokenContractEl.innerHTML = tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` + `<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") + copyableHtml(tx.contractAddress, "break-all") +
@@ -197,6 +160,7 @@ function render() {
"tx-detail-fee-section", "tx-detail-fee-section",
"tx-detail-gasprice-section", "tx-detail-gasprice-section",
"tx-detail-gasused-section", "tx-detail-gasused-section",
"tx-detail-network-section",
]) { ]) {
const el = $(id); const el = $(id);
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
@@ -209,17 +173,7 @@ function render() {
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
attachCopyHandlers("view-transaction");
document
.getElementById("view-transaction")
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
} }
function showDetailField(sectionId, contentId, value) { function showDetailField(sectionId, contentId, value) {
@@ -233,7 +187,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData) { function populateOnChainDetails(txData) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = `https://etherscan.io/block/${txData.block_number}`; const blockLink = `${currentNetwork().explorerUrl}/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section"); const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block"); const blockEl = $("tx-detail-block");
if (blockSection && blockEl) { if (blockSection && blockEl) {
@@ -285,6 +239,21 @@ function populateOnChainDetails(txData) {
); );
} }
// Show the network details wrapper if any child section is visible
const networkWrapper = $("tx-detail-network-section");
if (networkWrapper) {
const hasVisible = [
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
].some((id) => {
const el = $(id);
return el && !el.classList.contains("hidden");
});
if (hasVisible) networkWrapper.classList.remove("hidden");
}
// Bind copy handlers for newly added elements // Bind copy handlers for newly added elements
for (const id of [ for (const id of [
"tx-detail-block-section", "tx-detail-block-section",
@@ -339,19 +308,14 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
detailsHtml += `<div class="mb-2">`; detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address && d.isToken) { if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then dot + address + Etherscan link // Token entry: show symbol on its own line, then address via shared renderer
const dot = addressDotHtml(d.address);
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1]; const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) { if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
} }
const etherscanUrl = `https://etherscan.io/token/${d.address}`; detailsHtml += renderAddressHtml(d.address);
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else if (d.address) { } else if (d.address) {
// Protocol/contract entry: show name + Etherscan link detailsHtml += renderAddressHtml(d.address);
const dot = addressDotHtml(d.address);
const etherscanUrl = `https://etherscan.io/address/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
@@ -378,13 +342,7 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
// Bind copy handlers for new elements (including raw data now outside section) // Bind copy handlers for new elements (including raw data now outside section)
const copyTargets = [section, rawSection].filter(Boolean); const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) { for (const container of copyTargets) {
container.querySelectorAll("[data-copy]").forEach((el) => { attachCopyHandlers(container);
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
} }
} catch (e) { } catch (e) {
log.errorf("loadCalldata failed:", e.message); log.errorf("loadCalldata failed:", e.message);
@@ -394,11 +352,7 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => { $("btn-tx-back").addEventListener("click", () => {
if (state.selectedToken) { goBack();
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}); });
} }

View File

@@ -3,24 +3,19 @@
const { const {
$, $,
showView, showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
let ctx; let ctx;
let elapsedTimer = null; let elapsedTimer = null;
let pollTimer = null; let pollTimer = null;
@@ -37,50 +32,19 @@ function clearTimers() {
} }
function toAddressHtml(address) { function toAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
if (title) { return renderAddressHtml(address, { title });
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` +
extLink
);
}
return `<div class="flex items-center">${dot}<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`;
} }
function txHashHtml(hash) { function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`; const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
return (
`<span class="underline decoration-dashed cursor-pointer break-all" data-copy="${escapeHtml(hash)}">${escapeHtml(hash)}</span>` +
extLink
);
} }
function blockNumberHtml(blockNumber) { function blockNumberHtml(blockNumber) {
const num = String(blockNumber); const num = String(blockNumber);
const link = `https://etherscan.io/block/${num}`; const link = `${currentNetwork().explorerUrl}/block/${num}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; return copyableHtml(num) + etherscanLinkHtml(link);
return (
`<span class="underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
extLink
);
}
function attachCopyHandlers(viewId) {
document
.getElementById(viewId)
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
} }
function showWait(txInfo, txHash) { function showWait(txInfo, txHash) {
@@ -147,7 +111,7 @@ function tokenLabel(address) {
} }
function etherscanTokenLink(address) { function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`; return `${currentNetwork().explorerUrl}/token/${address}`;
} }
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
@@ -258,10 +222,16 @@ function navigateBack() {
window.close(); window.close();
return; return;
} }
// After a completed transaction, reset the navigation stack
// and go directly to the address view (token or detail).
// Use require() lazily to call show() without the ctx push wrapper.
clearViewStack();
state.viewStack.push("main");
if (state.selectedToken) { if (state.selectedToken) {
ctx.showAddressToken(); state.viewStack.push("address");
require("./addressToken").show();
} else { } else {
ctx.showAddressDetail(); require("./addressDetail").show();
} }
} }

View File

@@ -0,0 +1,114 @@
// Address warning module.
// Provides local and async (RPC-based) warning checks for Ethereum addresses.
// Returns arrays of {type, message, severity} objects.
const { isScamAddress } = require("./scamlist");
const { isBurnAddress } = require("./constants");
const { checkEtherscanLabel } = require("./etherscanLabels");
const { log } = require("./log");
/**
* Check an address against local-only lists (scam, burn, self-send).
* Synchronous — no network calls.
*
* @param {string} address - The target address to check.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @returns {Array<{type: string, message: string, severity: string}>}
*/
function getLocalWarnings(address, options = {}) {
const warnings = [];
const addr = address.toLowerCase();
if (isScamAddress(addr)) {
warnings.push({
type: "scam",
message:
"This address is on a known scam/fraud list. Do not send funds to this address.",
severity: "critical",
});
}
if (isBurnAddress(addr)) {
warnings.push({
type: "burn",
message:
"This is a known null/burn address. Funds sent here are permanently destroyed and cannot be recovered.",
severity: "critical",
});
}
if (options.fromAddress && addr === options.fromAddress.toLowerCase()) {
warnings.push({
type: "self-send",
message: "You are sending to your own address.",
severity: "warning",
});
}
return warnings;
}
/**
* Check an address against local lists AND via RPC queries.
* Async — performs network calls to check contract status and tx history.
*
* @param {string} address - The target address to check.
* @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
const warnings = getLocalWarnings(address, options);
let isContract = false;
try {
const code = await provider.getCode(address);
if (code && code !== "0x") {
isContract = true;
warnings.push({
type: "contract",
message:
"This address is a smart contract, not a regular wallet.",
severity: "warning",
});
}
} catch (e) {
log.errorf("contract check failed:", e.message);
}
// Skip tx count check for contracts — they may legitimately have
// zero inbound EOA transactions.
if (!isContract) {
try {
const txCount = await provider.getTransactionCount(address);
if (txCount === 0) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction. Double-check it is correct.",
severity: "info",
});
}
} catch (e) {
log.errorf("tx count check failed:", e.message);
}
}
// Etherscan label check (best-effort async — network failures are silent).
// Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try {
const etherscanWarning = await checkEtherscanLabel(address);
if (etherscanWarning) {
warnings.push(etherscanWarning);
}
} catch (e) {
log.errorf("etherscan label check failed:", e.message);
}
return warnings;
}
module.exports = { getLocalWarnings, getFullWarnings };

View File

@@ -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 };

View File

@@ -15,10 +15,15 @@ const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
// Use a static network to skip auto-detection (which can fail and cause // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
const mainnet = Network.from("mainnet"); // Accepts an optional networkName ("mainnet" or "sepolia") for the static
// network hint so ethers picks the right chain parameters. When omitted,
function getProvider(rpcUrl) { // reads the currently selected network from extension state.
return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet }); function getProvider(rpcUrl, networkName) {
// Lazy require to avoid circular dependency issues at module scope.
const { currentNetwork } = require("./state");
const name = networkName || currentNetwork().id;
const net = Network.from(name);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
} }
function formatBalance(wei) { function formatBalance(wei) {

35
src/shared/buildInfo.js Normal file
View File

@@ -0,0 +1,35 @@
// Build-time constants injected by esbuild define in build.js.
// These globals are replaced at bundle time with string literals.
/* global __BUILD_VERSION__, __BUILD_LICENSE__, __BUILD_AUTHOR__,
__BUILD_COMMIT__, __BUILD_COMMIT_FULL__, __BUILD_DATE__ */
const BUILD_VERSION =
typeof __BUILD_VERSION__ !== "undefined" ? __BUILD_VERSION__ : "dev";
const BUILD_LICENSE =
typeof __BUILD_LICENSE__ !== "undefined" ? __BUILD_LICENSE__ : "GPL-3.0";
const BUILD_AUTHOR =
typeof __BUILD_AUTHOR__ !== "undefined"
? __BUILD_AUTHOR__
: "sneak <sneak@sneak.berlin>";
const BUILD_COMMIT =
typeof __BUILD_COMMIT__ !== "undefined" ? __BUILD_COMMIT__ : "unknown";
const BUILD_COMMIT_FULL =
typeof __BUILD_COMMIT_FULL__ !== "undefined"
? __BUILD_COMMIT_FULL__
: "unknown";
const BUILD_DATE =
typeof __BUILD_DATE__ !== "undefined" ? __BUILD_DATE__ : "unknown";
const GITEA_COMMIT_URL =
"https://git.eeqj.de/sneak/AutistMask/commit/" + BUILD_COMMIT_FULL;
module.exports = {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_COMMIT_FULL,
BUILD_DATE,
GITEA_COMMIT_URL,
};

57
src/shared/chainSwitch.js Normal file
View File

@@ -0,0 +1,57 @@
// Consolidated chain-switch handler.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, background
// wallet_switchEthereumChain, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
const { networkById } = require("./networks");
const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain.
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = networkById(newNetworkId);
// --- core identity ---
state.networkId = net.id;
state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl;
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.).
clearPrices();
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
state.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
state.tokenHolderCache = {};
state.fraudContracts = [];
await saveState();
return net;
}
module.exports = { onChainSwitch };

View File

@@ -1,8 +1,32 @@
const DEBUG = true; // DEBUG is a build-time constant injected by esbuild's define in build.js
// (see src/shared/buildInfo.js for the same pattern). It is false unless the
// bundle was produced with AUTISTMASK_DEBUG=1, and it is false whenever the
// module is loaded outside a bundle (tests, plain require). It must never be
// derived from anything the user can change at runtime: it is what gates the
// hardcoded test mnemonic below.
/* global __BUILD_DEBUG__ */
const DEBUG = typeof __BUILD_DEBUG__ !== "undefined" ? __BUILD_DEBUG__ : false;
// Machine-readable record of the compiled DEBUG state, read out of the emitted
// bundles by script/verify-build. It is derived from DEBUG itself so the two
// cannot disagree, and it is a plain string literal rather than a minifier
// artifact like `DEBUG:!1`, so the check does not depend on esbuild's output
// staying byte-stable across versions.
//
// The ambiguity is the point. When DEBUG is known at build time the bundler
// folds this to exactly one of the two literals. When it is not — which is
// exactly what happens if the __BUILD_DEBUG__ define goes missing from
// build.js — the ternary survives, both literals appear in the bundle, and
// verify-build fails rather than guessing.
const BUILD_DEBUG_MARKER = DEBUG
? "autistmask-build-debug=on"
: "autistmask-build-debug=off";
const DEBUG_MNEMONIC = const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; "cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com"; const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -20,12 +44,29 @@ const ERC20_ABI = [
"function approve(address spender, uint256 amount) returns (bool)", "function approve(address spender, uint256 amount) returns (bool)",
]; ];
// Known null/burn addresses that permanently destroy funds.
const BURN_ADDRESSES = new Set([
"0x0000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000001",
"0x000000000000000000000000000000000000dead",
"0xdead000000000000000000000000000000000000",
"0x00000000000000000000000000000000deadbeef",
]);
function isBurnAddress(address) {
return BURN_ADDRESSES.has(address.toLowerCase());
}
module.exports = { module.exports = {
DEBUG, DEBUG,
BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL, DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
ERC20_ABI, ERC20_ABI,
BURN_ADDRESSES,
isBurnAddress,
}; };

View File

@@ -0,0 +1,107 @@
// Etherscan address label lookup via page scraping.
// Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently.
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
// Patterns in the page body that indicate a scam/phishing warning.
const SCAM_BODY_PATTERNS = [
/used in a\s+(?:\w+\s+)?phishing scam/i,
/used in a\s+(?:\w+\s+)?scam/i,
/wallet\s+drainer/i,
];
/**
* Parse the Etherscan address page HTML to extract label info.
* Exported for unit testing (no fetch needed).
*
* @param {string} html - Raw HTML of the Etherscan address page.
* @returns {{ label: string|null, isPhishing: boolean, warning: string|null }}
*/
function parseEtherscanPage(html) {
// Extract <title> content
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
let label = null;
let isPhishing = false;
let warning = null;
if (titleMatch) {
const title = titleMatch[1].trim();
// Title: "LABEL | Address: 0x... | Etherscan" or "Address: 0x... | Etherscan"
const labelMatch = title.match(/^(.+?)\s*\|\s*Address:/);
if (labelMatch) {
const candidate = labelMatch[1].trim();
// Only treat as a label if it's not just "Address" (unlabeled addresses)
if (candidate.toLowerCase() !== "address") {
label = candidate;
}
}
}
// Check label against phishing patterns
if (label) {
for (const pat of PHISHING_LABEL_PATTERNS) {
if (pat.test(label)) {
isPhishing = true;
warning = `Etherscan labels this address as "${label}" (Phish/Hack).`;
break;
}
}
}
// Check page body for scam warning banners
if (!isPhishing) {
for (const pat of SCAM_BODY_PATTERNS) {
if (pat.test(html)) {
isPhishing = true;
warning = label
? `Etherscan labels this address as "${label}" and reports it was used in a scam.`
: "Etherscan reports this address was flagged for phishing/scam activity.";
break;
}
}
}
return { label, isPhishing, warning };
}
/**
* Fetch an address page from Etherscan and check for scam/phishing labels.
* Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check).
*
* Uses the current network's explorer URL so the lookup works on both
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
*
* @param {string} address - Ethereum address to check.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address) {
try {
// Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state");
const etherscanBase = currentNetwork().explorerUrl + "/address/";
const resp = await fetch(etherscanBase + address, {
headers: { Accept: "text/html" },
});
if (!resp.ok) return null;
const html = await resp.text();
const result = parseEtherscanPage(html);
if (result.isPhishing) {
return {
type: "etherscan-phishing",
message: result.warning,
severity: "critical",
};
}
return null;
} catch {
// Network errors are expected — Etherscan may rate-limit or block.
return null;
}
}
module.exports = { parseEtherscanPage, checkEtherscanLabel };

View File

@@ -1,12 +1,27 @@
// Leveled logger. Outputs to console with [AutistMask] prefix. // Leveled logger. Outputs to console with [AutistMask] prefix.
// Level is DEBUG when the DEBUG constant is true, INFO otherwise. // Level is DEBUG when the compile-time DEBUG constant is true or the runtime
// debugMode state flag is enabled. The runtime flag is checked lazily so it
// responds immediately when toggled in settings.
const { DEBUG } = require("./constants"); const { DEBUG } = require("./constants");
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const threshold = DEBUG ? LEVELS.debug : LEVELS.info;
// Runtime debug mode flag — set by settings.js when the user toggles debug
// mode via the easter egg. Kept here as a simple mutable reference so it can
// be updated without circular dependency issues with state.js.
let _runtimeDebug = false;
function setRuntimeDebug(enabled) {
_runtimeDebug = enabled;
}
function isDebug() {
return DEBUG || _runtimeDebug;
}
function emit(level, method, args) { function emit(level, method, args) {
const threshold = isDebug() ? LEVELS.debug : LEVELS.info;
if (LEVELS[level] >= threshold) { if (LEVELS[level] >= threshold) {
console[method]("[AutistMask]", ...args); console[method]("[AutistMask]", ...args);
} }
@@ -37,4 +52,4 @@ async function debugFetch(url, opts) {
return resp; return resp;
} }
module.exports = { log, debugFetch }; module.exports = { log, debugFetch, setRuntimeDebug, isDebug };

57
src/shared/networks.js Normal file
View File

@@ -0,0 +1,57 @@
// Network definitions for supported Ethereum networks.
// Each network specifies its chain ID, default RPC and Blockscout endpoints,
// and the block explorer base URL used for address/tx/token/block links.
const NETWORKS = {
mainnet: {
id: "mainnet",
name: "Ethereum Mainnet",
chainId: "0x1",
networkVersion: "1",
nativeCurrency: "ETH",
defaultRpcUrl: "https://ethereum-rpc.publicnode.com",
defaultBlockscoutUrl: "https://eth.blockscout.com/api/v2",
explorerUrl: "https://etherscan.io",
isTestnet: false,
},
sepolia: {
id: "sepolia",
name: "Sepolia Testnet",
chainId: "0xaa36a7",
networkVersion: "11155111",
nativeCurrency: "SepoliaETH",
defaultRpcUrl: "https://ethereum-sepolia-rpc.publicnode.com",
defaultBlockscoutUrl: "https://eth-sepolia.blockscout.com/api/v2",
explorerUrl: "https://sepolia.etherscan.io",
isTestnet: true,
},
};
const SUPPORTED_CHAIN_IDS = new Set(
Object.values(NETWORKS).map((n) => n.chainId),
);
function networkById(id) {
return NETWORKS[id] || NETWORKS.mainnet;
}
function networkByChainId(chainId) {
for (const net of Object.values(NETWORKS)) {
if (net.chainId === chainId) return net;
}
return null;
}
// Build a block explorer link for the given path type and value.
// type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) {
return `${network.explorerUrl}/${type}/${value}`;
}
module.exports = {
NETWORKS,
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
explorerLink,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
// Domain-based phishing detection using a vendored blocklist with delta updates.
//
// A community-maintained phishing domain blocklist is vendored in
// phishingBlocklist.json and bundled at build time. At runtime, we fetch
// the live list periodically and keep only the delta (new entries not in
// the vendored list) in memory. This keeps runtime memory usage small.
//
// The domain-checker checks the in-memory delta first (fresh/recent scam
// sites), then falls back to the vendored list.
//
// If the delta is under 256 KiB it is persisted to localStorage so it
// survives extension/service-worker restarts.
const vendoredConfig = require("./phishingBlocklist.json");
const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const DELTA_STORAGE_KEY = "phishing-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
// Vendored set — built once from the bundled JSON.
const vendoredBlacklist = new Set(
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
);
// Delta set — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set();
let lastFetchTime = 0;
let fetchPromise = null;
let refreshTimer = null;
/**
* Load delta entries from localStorage on startup.
* Called once during module initialization in the background script.
*/
function loadDeltaFromStorage() {
try {
const raw = localStorage.getItem(DELTA_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.blacklist && Array.isArray(data.blacklist)) {
deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()),
);
}
} catch {
// localStorage unavailable or corrupt — start empty
}
}
/**
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES.
*/
function saveDeltaToStorage() {
try {
const data = {
blacklist: Array.from(deltaBlacklist),
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
localStorage.setItem(DELTA_STORAGE_KEY, json);
} else {
// Too large — remove stale key if present
localStorage.removeItem(DELTA_STORAGE_KEY);
}
} catch {
// localStorage unavailable — skip silently
}
}
/**
* Load a pre-parsed config and compute the delta against the vendored list.
* Used for both live fetches and testing.
*
* @param {{ blacklist?: string[] }} config
*/
function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
// Delta = entries in the live list that are NOT in the vendored list
deltaBlacklist = new Set(
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
);
lastFetchTime = Date.now();
saveDeltaToStorage();
}
/**
* Generate hostname variants for subdomain matching.
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
*
* @param {string} hostname
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase();
const variants = [h];
const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
for (let i = 1; i < parts.length - 1; i++) {
variants.push(parts.slice(i).join("."));
}
return variants;
}
/**
* Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
*/
function isPhishingDomain(hostname) {
if (!hostname) return false;
const variants = hostnameVariants(hostname);
// Check delta blacklist first (fresh/recent scam sites), then vendored
for (const v of variants) {
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
}
return false;
}
/**
* Fetch the latest blocklist and compute delta against vendored data.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
*
* @returns {Promise<void>}
*/
async function updatePhishingList() {
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
return;
}
// De-duplicate concurrent calls
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
try {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json();
loadConfig(config);
} catch {
// Silently fail — vendored list still provides coverage.
// We'll retry next time.
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* Start periodic refresh of the phishing list.
* Should be called once from the background script on startup.
*/
function startPeriodicRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS);
}
/**
* Return the total blocklist size (vendored + delta) for diagnostics.
*
* @returns {number}
*/
function getBlocklistSize() {
return vendoredBlacklist.size + deltaBlacklist.size;
}
/**
* Return the delta blocklist size for diagnostics.
*
* @returns {number}
*/
function getDeltaSize() {
return deltaBlacklist.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
deltaBlacklist = new Set();
lastFetchTime = 0;
fetchPromise = null;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
_reset,
// Exposed for testing only
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
_getDeltaBlacklist: () => deltaBlacklist,
};

View File

@@ -8,6 +8,13 @@ const prices = {};
let lastFetchedAt = 0; let lastFetchedAt = 0;
async function refreshPrices() { async function refreshPrices() {
// Testnet tokens have no real market value — skip price fetching
// and clear any stale mainnet prices so the UI shows no USD values.
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) {
clearPrices();
return;
}
const now = Date.now(); const now = Date.now();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return; if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
try { try {
@@ -19,7 +26,19 @@ async function refreshPrices() {
} }
} }
// Clear all cached prices and reset the fetch timestamp so the
// next refreshPrices() call will fetch fresh data.
function clearPrices() {
for (const key of Object.keys(prices)) {
delete prices[key];
}
lastFetchedAt = 0;
}
// Return the USD price for a symbol, or null on testnet / unknown.
function getPrice(symbol) { function getPrice(symbol) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
@@ -37,6 +56,8 @@ function formatUsd(amount) {
} }
function getAddressValueUsd(addr) { function getAddressValueUsd(addr) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
const ethBal = parseFloat(addr.balance || "0"); const ethBal = parseFloat(addr.balance || "0");
@@ -51,6 +72,8 @@ function getAddressValueUsd(addr) {
} }
function getWalletValueUsd(wallet) { function getWalletValueUsd(wallet) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
@@ -60,6 +83,8 @@ function getWalletValueUsd(wallet) {
} }
function getTotalValueUsd(wallets) { function getTotalValueUsd(wallets) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -71,6 +96,7 @@ function getTotalValueUsd(wallets) {
module.exports = { module.exports = {
prices, prices,
refreshPrices, refreshPrices,
clearPrices,
getPrice, getPrice,
formatUsd, formatUsd,
getAddressValueUsd, getAddressValueUsd,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
// State management and extension storage persistence. // State management and extension storage persistence.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants"); const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
const storageApi = const storageApi =
typeof browser !== "undefined" typeof browser !== "undefined"
@@ -11,6 +12,7 @@ const DEFAULT_STATE = {
hasWallet: false, hasWallet: false,
wallets: [], wallets: [],
trackedTokens: [], trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL, rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
lastBalanceRefresh: 0, lastBalanceRefresh: 0,
@@ -27,6 +29,7 @@ const DEFAULT_STATE = {
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system", theme: "system",
debugMode: false,
}; };
const state = { const state = {
@@ -36,13 +39,20 @@ const state = {
selectedAddress: null, selectedAddress: null,
selectedToken: null, selectedToken: null,
viewData: {}, viewData: {},
viewStack: [],
}; };
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
}
async function saveState() { async function saveState() {
const persisted = { const persisted = {
hasWallet: state.hasWallet, hasWallet: state.hasWallet,
wallets: state.wallets, wallets: state.wallets,
trackedTokens: state.trackedTokens, trackedTokens: state.trackedTokens,
networkId: state.networkId,
rpcUrl: state.rpcUrl, rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl, blockscoutUrl: state.blockscoutUrl,
lastBalanceRefresh: state.lastBalanceRefresh, lastBalanceRefresh: state.lastBalanceRefresh,
@@ -59,11 +69,13 @@ async function saveState() {
fraudContracts: state.fraudContracts, fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache, tokenHolderCache: state.tokenHolderCache,
theme: state.theme, theme: state.theme,
debugMode: state.debugMode,
currentView: state.currentView, currentView: state.currentView,
selectedWallet: state.selectedWallet, selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress, selectedAddress: state.selectedAddress,
selectedToken: state.selectedToken, selectedToken: state.selectedToken,
viewData: state.viewData, viewData: state.viewData,
viewStack: state.viewStack,
}; };
await storageApi.set({ autistmask: persisted }); await storageApi.set({ autistmask: persisted });
} }
@@ -75,6 +87,7 @@ async function loadState() {
state.hasWallet = saved.hasWallet; state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
state.trackedTokens = saved.trackedTokens || []; state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl = state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
@@ -117,6 +130,8 @@ async function loadState() {
state.fraudContracts = saved.fraudContracts || []; state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {}; state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system"; state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null; state.currentView = saved.currentView || null;
state.selectedWallet = state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null; saved.selectedWallet !== undefined ? saved.selectedWallet : null;
@@ -124,6 +139,7 @@ async function loadState() {
saved.selectedAddress !== undefined ? saved.selectedAddress : null; saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null; state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {}; state.viewData = saved.viewData || {};
state.viewStack = Array.isArray(saved.viewStack) ? saved.viewStack : [];
} }
} }
@@ -134,4 +150,10 @@ function currentAddress() {
return state.wallets[state.selectedWallet].addresses[state.selectedAddress]; return state.wallets[state.selectedWallet].addresses[state.selectedAddress];
} }
module.exports = { state, saveState, loadState, currentAddress }; module.exports = {
state,
saveState,
loadState,
currentAddress,
currentNetwork,
};

View File

@@ -5,6 +5,10 @@ const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants"); const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateMnemonic() { function generateMnemonic() {
// This must stay the compile-time DEBUG constant. Do NOT switch it to
// isDebug() from log.js: that also ORs in the runtime debugMode flag the
// settings toggle drives, which would let a user of a release build turn
// the hardcoded, publicly known test phrase back on for real wallets.
if (DEBUG) return DEBUG_MNEMONIC; if (DEBUG) return DEBUG_MNEMONIC;
const m = Mnemonic.fromEntropy( const m = Mnemonic.fromEntropy(
globalThis.crypto.getRandomValues(new Uint8Array(16)), globalThis.crypto.getRandomValues(new Uint8Array(16)),

View File

@@ -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 };

View File

@@ -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/);
});
});

View File

@@ -1,4 +1,6 @@
const { const {
DEBUG,
BUILD_DEBUG_MARKER,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
@@ -19,6 +21,24 @@ describe("constants", () => {
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); 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", () => { test("exports ERC-20 ABI with expected functions", () => {
expect(Array.isArray(ERC20_ABI)).toBe(true); expect(Array.isArray(ERC20_ABI)).toBe(true);
expect(ERC20_ABI.length).toBeGreaterThan(0); expect(ERC20_ABI.length).toBeGreaterThan(0);

289
tests/e2e/harness.js Normal file
View File

@@ -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,
};

334
tests/e2e/network.js Normal file
View File

@@ -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/<addr>/... 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<string|null>}>}
*/
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: "<html><body></body></html>",
});
}
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,
};

264
tests/e2e/run.js Normal file
View File

@@ -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;
});

View File

@@ -0,0 +1,100 @@
const { parseEtherscanPage } = require("../src/shared/etherscanLabels");
describe("etherscanLabels", () => {
describe("parseEtherscanPage", () => {
test("detects Fake_Phishing label in title", () => {
const html = `<html><head><title>Fake_Phishing184810 | Address: 0x00000c07...3ea470000 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Fake_Phishing184810");
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("Fake_Phishing184810");
expect(result.warning).toContain("Phish/Hack");
});
test("detects Fake_Phishing with different number", () => {
const html = `<html><head><title>Fake_Phishing5169 | Address: 0x3e0defb8...99a7a8a74 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Fake_Phishing5169");
expect(result.isPhishing).toBe(true);
});
test("detects Exploiter label", () => {
const html = `<html><head><title>Exploiter 42 | Address: 0xabcdef...1234 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Exploiter 42");
expect(result.isPhishing).toBe(true);
});
test("detects scam warning in body text", () => {
const html =
`<html><head><title>Address: 0xabcdef...1234 | Etherscan</title></head>` +
`<body>There are reports that this address was used in a Phishing scam.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("phishing/scam");
});
test("detects scam warning with label in body", () => {
const html =
`<html><head><title>SomeScammer | Address: 0xabcdef...1234 | Etherscan</title></head>` +
`<body>There are reports that this address was used in a scam.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("SomeScammer");
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("SomeScammer");
});
test("returns clean result for legitimate address", () => {
const html = `<html><head><title>vitalik.eth | Address: 0xd8dA6BF2...37aA96045 | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("vitalik.eth");
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("returns clean result for unlabeled address", () => {
const html = `<html><head><title>Address: 0x1234567890...abcdef | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("handles exchange labels correctly (not phishing)", () => {
const html = `<html><head><title>Coinbase 10 | Address: 0xa9d1e08c...b81d3e43 | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Coinbase 10");
expect(result.isPhishing).toBe(false);
});
test("handles contract names correctly (not phishing)", () => {
const html = `<html><head><title>Beacon Deposit Contract | Address: 0x00000000...03d7705Fa | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Beacon Deposit Contract");
expect(result.isPhishing).toBe(false);
});
test("handles empty HTML gracefully", () => {
const result = parseEtherscanPage("");
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("handles malformed title tag", () => {
const html = `<html><head><title></title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
});
test("detects wallet drainer warning", () => {
const html =
`<html><head><title>Address: 0xabc...def | Etherscan</title></head>` +
`<body>This is a known wallet drainer contract.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.isPhishing).toBe(true);
});
});
});

View File

@@ -0,0 +1,205 @@
// Provide a localStorage mock for Node.js test environment.
// Must be set before requiring the module since it calls loadDeltaFromStorage()
// at module load time.
const localStorageStore = {};
global.localStorage = {
getItem: (key) =>
Object.prototype.hasOwnProperty.call(localStorageStore, key)
? localStorageStore[key]
: null,
setItem: (key, value) => {
localStorageStore[key] = String(value);
},
removeItem: (key) => {
delete localStorageStore[key];
},
};
const {
isPhishingDomain,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
_reset,
_getVendoredBlacklistSize,
_getDeltaBlacklist,
} = require("../src/shared/phishingDomains");
// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
beforeEach(() => {
_reset();
// Clear localStorage mock between tests
for (const key of Object.keys(localStorageStore)) {
delete localStorageStore[key];
}
});
describe("phishingDomains", () => {
describe("vendored blocklist", () => {
test("vendored blacklist is loaded from bundled JSON", () => {
// The vendored blocklist should have a large number of entries
expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
});
test("detects domains from vendored blacklist", () => {
// These are well-known phishing domains in the vendored list
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("getBlocklistSize includes vendored entries", () => {
expect(getBlocklistSize()).toBeGreaterThan(100000);
});
});
describe("hostnameVariants", () => {
test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com");
expect(variants).toEqual(["sub.evil.com", "evil.com"]);
});
test("returns just the hostname for a bare domain", () => {
const variants = hostnameVariants("example.com");
expect(variants).toEqual(["example.com"]);
});
test("handles deep subdomain chains", () => {
const variants = hostnameVariants("a.b.c.d.com");
expect(variants).toEqual([
"a.b.c.d.com",
"b.c.d.com",
"c.d.com",
"d.com",
]);
});
test("lowercases hostnames", () => {
const variants = hostnameVariants("Evil.COM");
expect(variants).toEqual(["evil.com"]);
});
});
describe("delta computation via loadConfig", () => {
test("loadConfig computes delta of new entries not in vendored list", () => {
loadConfig({
blacklist: [
"brand-new-scam-site-xyz123.com",
"hopprotocol.pro", // already in vendored
],
});
// Only the new domain should be in the delta
expect(
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"),
).toBe(true);
expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
expect(getDeltaSize()).toBe(1);
});
test("re-loading config replaces previous delta", () => {
loadConfig({
blacklist: ["first-scam-xyz.com"],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);
loadConfig({
blacklist: ["second-scam-xyz.com"],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
});
test("getBlocklistSize includes both vendored and delta", () => {
const baseSize = getBlocklistSize();
loadConfig({
blacklist: ["delta-only-scam-xyz.com"],
});
expect(getBlocklistSize()).toBe(baseSize + 1);
});
});
describe("isPhishingDomain with delta + vendored", () => {
test("detects domain from delta blacklist", () => {
loadConfig({
blacklist: ["fresh-scam-xyz.com"],
});
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
});
test("detects domain from vendored blacklist", () => {
// No delta loaded — vendored still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
test("returns false for clean domains", () => {
expect(isPhishingDomain("etherscan.io")).toBe(false);
expect(isPhishingDomain("example.com")).toBe(false);
});
test("detects subdomain of blacklisted domain (vendored)", () => {
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
});
test("detects subdomain of blacklisted domain (delta)", () => {
loadConfig({
blacklist: ["delta-phish-xyz.com"],
});
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
});
test("case-insensitive matching", () => {
loadConfig({
blacklist: ["Delta-Scam-XYZ.COM"],
});
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
});
test("returns false for empty/null hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
});
test("handles config with no blacklist key", () => {
loadConfig({});
expect(getDeltaSize()).toBe(0);
// Vendored list still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
});
describe("localStorage persistence", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => {
loadConfig({
blacklist: ["persisted-scam-xyz.com"],
});
const stored = localStorage.getItem("phishing-delta");
expect(stored).not.toBeNull();
const data = JSON.parse(stored);
expect(data.blacklist).toContain("persisted-scam-xyz.com");
});
test("delta is cleared on _reset", () => {
loadConfig({
blacklist: ["temp-scam-xyz.com"],
});
expect(getDeltaSize()).toBe(1);
_reset();
expect(getDeltaSize()).toBe(0);
});
});
describe("real-world blocklist patterns", () => {
test("detects known phishing domains from vendored list", () => {
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("does not flag legitimate domains", () => {
expect(isPhishingDomain("opensea.io")).toBe(false);
expect(isPhishingDomain("etherscan.io")).toBe(false);
});
});
});

1002
tests/transactions.test.js Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,94 @@
// Tests for the DEBUG build flag as it gates mnemonic generation.
//
// 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
// case; the debug-build case is exercised by defining the global and
// re-requiring the modules with a fresh registry.
const WORDS_IN_12_WORD_PHRASE = 12;
function loadWallet() {
const constants = require("../src/shared/constants");
const wallet = require("../src/shared/wallet");
const log = require("../src/shared/log");
return { constants, wallet, log };
}
describe("generateMnemonic in a release build", () => {
beforeEach(() => {
jest.resetModules();
delete globalThis.__BUILD_DEBUG__;
});
test("DEBUG defaults to false when the build define is absent", () => {
const { constants } = loadWallet();
expect(constants.DEBUG).toBe(false);
});
test("returns fresh, valid 12-word phrases that are not the test phrase", () => {
const { constants, wallet } = loadWallet();
const first = wallet.generateMnemonic();
const second = wallet.generateMnemonic();
expect(first).not.toBe(second);
for (const phrase of [first, second]) {
expect(wallet.isValidMnemonic(phrase)).toBe(true);
expect(phrase.split(" ")).toHaveLength(WORDS_IN_12_WORD_PHRASE);
expect(phrase).not.toBe(constants.DEBUG_MNEMONIC);
}
});
test("derives a usable HD wallet from the generated phrase", () => {
const { wallet } = loadWallet();
const { xpub, firstAddress } = wallet.hdWalletFromMnemonic(
wallet.generateMnemonic(),
);
expect(xpub.startsWith("xpub")).toBe(true);
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
test("the runtime debug toggle cannot re-enable the test phrase", () => {
const { constants, wallet, log } = loadWallet();
// What the settings easter-egg toggle does at runtime.
log.setRuntimeDebug(true);
expect(log.isDebug()).toBe(true);
const phrase = wallet.generateMnemonic();
expect(phrase).not.toBe(constants.DEBUG_MNEMONIC);
expect(wallet.isValidMnemonic(phrase)).toBe(true);
expect(phrase).not.toBe(wallet.generateMnemonic());
log.setRuntimeDebug(false);
});
});
describe("generateMnemonic in a debug build", () => {
beforeEach(() => {
jest.resetModules();
globalThis.__BUILD_DEBUG__ = true;
});
afterEach(() => {
delete globalThis.__BUILD_DEBUG__;
});
test("DEBUG is true and the test phrase is returned", () => {
const { constants, wallet } = loadWallet();
expect(constants.DEBUG).toBe(true);
expect(wallet.generateMnemonic()).toBe(constants.DEBUG_MNEMONIC);
});
test("the test phrase is itself a valid 12-word BIP-39 phrase", () => {
const { constants, wallet } = loadWallet();
expect(wallet.isValidMnemonic(constants.DEBUG_MNEMONIC)).toBe(true);
expect(constants.DEBUG_MNEMONIC.split(" ")).toHaveLength(
WORDS_IN_12_WORD_PHRASE,
);
});
});

129
tests/walletDelete.test.js Normal file
View File

@@ -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",
});
});
});

View File

@@ -2547,6 +2547,11 @@ pkg-dir@^4.2.0:
dependencies: dependencies:
find-up "^4.0.0" 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: pngjs@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz"