Compare commits

..

114 Commits

Author SHA1 Message Date
031a70e0b6 feat: vendor and censor the phishing blocklist at build time (closes #219)
All checks were successful
check / check (push) Successful in 53s
e2e / e2e-chrome (push) Successful in 1m6s
e2e / e2e-firefox (push) Successful in 41s
The blocklist URL in shipped code named a competitor and pointed at a moving
ref, and the extension re-fetched from it every 24 hours, which also meant a
third party decided what this wallet warns about. All of that is gone.

script/vendor-blocklist fetches upstream at a pinned commit, verifies the
sha256 of the bytes that commit serves, and writes
src/shared/phishingBlocklist.json. It is build-time tooling, never shipped, and
the one place in the repo that names the upstream project; a source reference
nobody can verify is not a source reference.

The artifact stores truncated sha256 digests rather than domain names. That is
what censors it: the previous file contained the competitor's name 6,475 times,
as phishing domains impersonating them, and not one of those domains is
dropped. It also makes lookups a binary search over a fixed-width string, so
nothing is built at module load — which matters on MV3, where the worker
re-evaluates the module on every wake — and takes the file from 8.7 MB to
1.7 MB.

script/check-censored enforces the rest: it reads the name out of the vendoring
script rather than repeating it, and fails on any occurrence in the working
tree or under dist/ that is not one of the three literals shipped code cannot
avoid — two provider-shim identifiers in src/content/inpage.js and one ERC-20's
on-chain name in src/shared/tokenList.js. Each is permitted only at the path
that carries it, and at the emitted paths that path is bundled into, so a
literal appearing anywhere else fails like any other occurrence. It runs in
make check, which inspects dist/ when there is one and says loudly when there
is not, and again with --require-dist at the end of every make build.

Removing the runtime fetch retires the delta, the extension-storage persistence
and the 24-hour alarm from #158. A retired alarm is now cleared rather than
left waking the worker forever on installs that already have it.

The e2e suite drives the warning end to end from a real blocklisted origin
served as a real http(s) site, with a control asserting the banner stays hidden
for one that is not listed. Its service-worker interception canary needed a new
anchor, since the startup fetch it used to watch for no longer happens: it now
wakes the worker with a message and asks it for one throwaway fetch.

LICENSE no longer cites a repository that returns 404.

eslint.config.js gains one block: script/lib/ holds node programs the shell
entrypoints call, and without it they lint with no globals at all.
2026-08-17 07:48:47 +00:00
8fcdd8a053 fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 38s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 23s
2026-08-17 09:34:07 +02:00
7690fe6429 fix: answer the page when a background handler throws (closes #280)
All checks were successful
check / check (push) Successful in 36s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 22s
2026-08-17 09:16:21 +02:00
a60c4a616a test: drive the Settings screen in a browser and guard every popup element id (closes #229)
Some checks failed
check / check (push) Has been cancelled
e2e / e2e-chrome (push) Has been cancelled
e2e / e2e-firefox (push) Has been cancelled
2026-08-17 09:14:59 +02:00
47bf38644d build: add ESLint to script/lint and containerize linting (closes #152)
All checks were successful
check / check (push) Successful in 39s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 24s
2026-08-17 09:10:03 +02:00
4b7a678a9b refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 45s
e2e / e2e-firefox (push) Successful in 23s
2026-08-17 09:06:13 +02:00
ab1c1846a7 fix: one wording for an empty password field on every screen (closes #265)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 18s
2026-08-17 08:59:59 +02:00
743b1962a5 build: run the browser e2e suites in CI (closes #259)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 47s
e2e / e2e-firefox (push) Successful in 20s
2026-08-17 08:52:26 +02:00
c06765ef8f fix: one transaction approval at a time, and honest copy for a nonce collision (closes #271)
All checks were successful
check / check (push) Successful in 28s
2026-08-17 08:38:26 +02:00
e07efb710a fix: an address holding only unpriced tokens is no longer totalled at $0.00 (closes #261)
Some checks failed
check / check (push) Has been cancelled
2026-08-17 08:38:10 +02:00
d9d50f05d2 test: assert the #150 and #151 items the harness did not cover (closes #188)
All checks were successful
check / check (push) Successful in 27s
Covers the four DoD items #188 scoped: the AddToken quick-pick populating the address field, Back out of AddToken unwinding the persisted navigation stack exactly once, a native ETH transfer rendering in TransactionDetail with no token contract row, and tap-to-copy reading the real clipboard back.

The native path needed a fixture: the Blockscout normal-transactions endpoint answered [] unconditionally, so there was no non-ERC-20 row to open.

Each assertion demonstrated to discriminate by mutation, one break at a time with the pre-existing tests staying green: a double viewStack push, a no-op quick-pick handler, an un-hidden token contract row, the ERC-20 branch forced onto a native transaction, and a dropped clipboard write each turn exactly the corresponding test red and no other.

Four further DoD items from #150 and #151 remain outside this scope and are tracked in #295.
2026-08-17 08:10:37 +02:00
51e84aefa6 docs: drop the README limit that #274 fixed (closes #285)
All checks were successful
check / check (push) Successful in 26s
The EIP-1193 error code now survives the last hop into the page, so the third standing limit in the End-to-End Tests section no longer describes the code. Verified against src/content/inpage.js (ProviderRpcError carries code and data) and tests/e2e/run.js (the page-side error is asserted, not only the wire).
2026-08-17 08:03:02 +02:00
0be20d7270 fix: render the view "Back" lands on after the popup is reopened (closes #268)
All checks were successful
check / check (push) Successful in 1m27s
2026-08-14 06:14:09 +02:00
9dcd875dd4 fix: carry EIP-1193 error codes through to the page (closes #274)
All checks were successful
check / check (push) Successful in 28s
The provider rebuilt every rejection as a bare Error carrying only a message,
so a dApp checking err.code === 4001 saw undefined and could not tell a user's
deliberate refusal from a failure. Well-behaved sites therefore showed an error
or retried instead of accepting the refusal. The code was produced correctly
and did cross the extension boundary; it was lost in the last hop.

Rejections now reach the page as a ProviderRpcError carrying code, and data
where present. The code is passed through verbatim rather than matched against
a whitelist, so a code added upstream later needs no change here. An error that
genuinely has no code stays a plain Error with no code property at all, rather
than advertising code: undefined -- 'code' in err is what a careful dApp asks.

Messages are unchanged for every path, verified byte-for-byte against the
previous provider across every background error shape.

The end-to-end assertion that printed the observed code now requires it.
2026-08-12 13:47:33 +02:00
c755a5e944 fix: a shared ticker no longer hides one of its two real tokens (closes #276)
All checks were successful
check / check (push) Successful in 33s
Seven bundled tokens were filtered as spoofs at their own address, so a user
holding FRAX, TON, REUSD, EURE, MSUSD, MUSD or JPYC could not see or spend the
one the wallet happened not to pick.

The known-symbol table is derived from the bundled token list, first-wins in
market-cap order, so a symbol that appears twice silently condemned its second
contract. Both are real tokens from the same fetch and neither is stale --
three pairs are one issuer's old and new contract, four are unrelated issuers
sharing a ticker. Picking a winner would have been guessing, and dropping the
ambiguous symbols would have ended spoof filtering for those tickers entirely.

The table now maps a symbol to the set of addresses that legitimately bear it.
A contract outside the set is still a spoof, so the check is not weakened: a
third contract bearing any of the seven shared tickers is refused, and that is
tested. The filter decides what is fake, not what is worth holding, so a legacy
contract stays in the set -- it still holds real balances.

A test walks the whole bundled list asserting no token is filtered at its own
address, which is the guard whose absence let this ship.
2026-08-12 13:31:55 +02:00
d5595c0151 test: drive the EIP-1193 dApp approval round trips in the browser (closes #183)
All checks were successful
check / check (push) Successful in 29s
The dApp signing path was the largest unverified surface in the milestone: the
only place where the content script, the inpage provider, the background worker
and the popup all have to work together, with unit tests covering each side in
isolation and none covering the seam.

A page served by the harness speaks EIP-1193 to the real provider -- asserted by
EIP-6963 object identity, not by shape -- and eth_requestAccounts, personal_sign,
eth_signTypedData_v4 and eth_sendTransaction are each driven through to approval
and to rejection.

Every signature is recovered and compared to the approved address; the broadcast
transaction is parsed from the bytes captured at eth_sendRawTransaction and
checked for signer, recipient, value, calldata and chain. A signature that
merely came back would pass against a wrong key, a wrong message or a wrong
chain, so each assertion was demonstrated failing against a variant that is
wrong in exactly one of those ways.

The password is asserted absent from every message crossing the extension
boundary, which gives #157's fix a permanent floor rather than a one-time
review.

Two defects this surfaced are tracked separately: EIP-1193 error codes never
reach the page (#274), and approving a site connection races the popup teardown
(#275). Neither is asserted as correct here. A real dApp with real funds against
mainnet remains an uncovered human pass and is documented as such.
2026-08-12 13:23:57 +02:00
e4c3708b84 fix: fold invisible characters before the known-symbol spoof check (closes #260)
All checks were successful
check / check (push) Successful in 36s
A token calling itself " ETH " missed the known-symbol table entirely, so the
spoof check reported it was not a spoof -- while HTML collapsed the whitespace
and displayed it as ETH next to the user's real ETH. One space defeated the
filter.

The symbol is now folded before the lookup: NFKC, remove what paints nothing,
trim, uppercase. The rule is "remove what paints nothing"; the Unicode classes
are how that is spelled, which is why U+007F is named separately -- it is a
control, reached by no class, and measures identical to no character at all.

Every width in the module comment was measured in the pinned browser rather
than reasoned about, and the boundary is pinned from both sides: widening to
all control characters fails the visible-controls test, narrowing back fails
the invisible-characters test. Two default-ignorable code points do paint a
box and are folded anyway, which can only hide a token that does not resemble
the symbol it folds to -- the harmless direction, recorded rather than glossed.

Confusables that are distinct letters, bidi reordering and interior whitespace
are knowingly left open and asserted open by tests.
2026-08-12 13:06:54 +02:00
52c7c1b060 test: containerized Firefox end-to-end harness (closes #184)
All checks were successful
check / check (push) Successful in 34s
Drives the real popup in a real Firefox with dist/firefox/ installed as an
unpacked MV2 temporary add-on via geckodriver. make test-e2e-firefox, outside
make check like the Chrome suite. Zero npm dependencies: plain fetch and
child_process against geckodriver's HTTP API. Base image, Firefox tarball and
geckodriver are each pinned by digest and verified at build time.

Error capture reads the privileged console service through Marionette's chrome
context, not WebDriver BiDi. BiDi delivers nothing at all for extension pages,
so a BiDi-based harness would observe zero events and report success -- the
vacuous-check shape this repo has shipped twice. Both the driver and the README
say so where someone would be tempted to simplify.

Demonstrated to discriminate: a background page that throws at the top of the
file, a missing import, and an async throw where every UI assertion still
passes each fail the run.

Three limits are measured and documented rather than papered over: capture is
poll-based so an error is attributed to a step, not a moment; the console ring
buffer holds 250 messages and evicts the oldest, measured against a clean-run
peak of 4; and the drained window ends roughly 1.5s after the last step, with
observed jitter rather than a hard boundary. Content-script capture is marked
unverified because --network none leaves no page to inject into, and that same
choice inverts coverage of network-dependent code.
2026-08-12 12:20:14 +02:00
918e581ef3 harden: verify the signed transaction against what the popup displayed (closes #216)
Some checks failed
check / check (push) Has been cancelled
Verification compared the signed artifact against the dApp's request object.
For every field the dApp omitted -- normally nonce, gas limit and all the fee
fields, since the popup filled them in -- the number the user actually read on
screen was verified by nothing, and only absolute ceilings stood behind it.

The transaction is now populated in the background before the approval window
opens, and that populated object is both what the popup displays and what the
signed artifact is verified against. Every consequential field becomes an
equality comparison; the ceilings remain as a backstop. Population failing
means no approval and no window, and the error goes to the requesting page --
earlier than before, where the same estimate failed after the password had been
typed.

The account is pinned too: `from` is compared against the address named at
approval time rather than whichever address is active at signing, so switching
accounts mid-flow refuses instead of signing from an account the approval did
not name. The message-signing path had the same defect and gets the same fix.

Nonce selection moves earlier as a consequence; the concurrent-approval case
that follows from it is tracked at #271.
2026-08-12 12:15:25 +02:00
a08ba6a66d fix: filter the restored view stack against RESTORABLE_VIEWS (closes #224)
Some checks failed
check / check (push) Has been cancelled
The persisted view stack was restored verbatim. RESTORABLE_VIEWS stopped the
popup opening ONTO a view it will not re-render, but nothing kept such a view
out of the stack, so Back could land on a screen whose content was deliberately
never restored. No secret leaks -- those views are blank precisely because
nothing is restored into them; this is a navigation defect.

loadState() now truncates the stored stack at the first entry outside
RESTORABLE_VIEWS, dropping it and everything above it. Truncating rather than
splicing keeps the result a prefix of what was stored, so every surviving entry
keeps the Back target it had; splicing would silently re-point the entry above
the hole at a different screen. Filtering on load rather than on save is what
makes it retroactive for stacks already in storage, and leaves the live
in-session stack whole, which it should be.

The general case where Back lands on a blank screen even for restorable views,
because goBack() re-renders nothing, is separate and tracked at #268.
2026-08-12 12:07:48 +02:00
09b602579a fix: one password-failure message across every screen (closes #172)
Some checks failed
check / check (push) Has been cancelled
A rejected password was reported three different ways depending on which screen
you were on, including the fragment "Wrong password." which is not a sentence.
All six decryptWithPassword call sites now show the same full sentence.

Strings only -- a wrong password still fails closed on every screen and still
resolves no pending approval.

A test pins the invariant per call site: each decryptWithPassword call is walked
out to its enclosing try and forward to that block's catch, and the prose shown
there must equal the canonical sentence. Per-file matching was not enough, since
a file with two call sites kept passing while one of them diverged.
2026-08-12 12:03:39 +02:00
18b47cd579 test: close the empty-batch hole in the e2e unstubbed-request guard (closes #187)
Some checks failed
check / check (push) Has been cancelled
The guard that reports unrecognised POST bodies used batch.every(), which is
vacuously true on an empty array, so a POST with body [] was answered 200 []
and escaped the one mechanism whose job is to make unrecognised outbound
traffic fail the suite rather than pass silently. Unreachable in practice
today, which is exactly the qualifier that stops being true later.

The comment explaining the guard also described a mechanism that does not
exist: playwright-core decodes a binary body lossily rather than returning
null, so such a body reaches the JSON parse as mojibake and is reported by the
catch, while only an absent or empty body decodes to null and is reported by
the type guard. Both are reported; the comment now describes the two real
routes.
2026-08-12 11:50:38 +02:00
5af89a1b63 test: drive ConfirmTx in the e2e suite, gate assertion included (closes #238)
All checks were successful
check / check (push) Successful in 38s
ConfirmTx -- the screen that decides what gets signed -- had no automated
coverage of its own behaviour. The arithmetic underneath was well tested; the
wiring was not, so a mutant making the spend gate read the displayed fee
estimate instead of the reserve would have reintroduced the #154 overspend with
the suite still green.

Nine end-to-end tests now drive it for both the native and ERC-20 paths,
covering the pending, funded, over-balance and estimate-failed states, and
asserting that the gate reads the reserve rather than the estimate. Swapping the
two makes the suite fail. The view height is asserted constant across every
state transition rather than merely printed.

Reaching the screen needs a funded balance and a gas estimate, so the route
interception gains fixtures for both. Testing the estimate-failed state means
provoking the console error the code is supposed to emit, which the harness
otherwise fails a run on; an expectation mechanism consumes exactly one matching
record, is scoped to the declaring test, and fails that test if nothing matched,
so it cannot mask an unrelated error.
2026-08-12 11:35:20 +02:00
c6a1f97247 fix: explain a rejected dust threshold instead of silently snapping back (closes #233)
All checks were successful
check / check (push) Successful in 33s
The dust-threshold field was the only validated input in Settings that rejected
without saying anything: the value silently changed back to the stored one with
no explanation. It now flashes "Please enter a whole number of gwei, zero or
greater." alongside the existing resync, matching the idiom the RPC URL field
already uses.

The parse moves to its own module and accepts plain decimal digits only, zero
or greater. Hex and exponent notation are refused rather than accepted: Number()
reads "0x10" as 16 and "1e3" as 1000, neither of which the previous parseInt
produced, and storing a number the user did not type is the same silent
substitution this change exists to remove.

The message must fit one line of the reserved flash area -- a wrapped message
pushes the settings view down, which the No Layout Shift policy forbids. That is
pinned by an end-to-end test measuring the rendered line height and the position
of the elements below it, in a single round trip because the flash clears after
two seconds.
2026-08-12 11:29:09 +02:00
0a1786b406 harden: verify all approval fields and make failed signing retryable (closes #174)
All checks were successful
check / check (push) Successful in 30s
approvalVerify now compares every field of the signed artifact against the
approval, not a subset. Transaction types are allowlisted to 0/1/2 and any
field the module does not check is refused outright, so a future transaction
type cannot smuggle consequential fields past verification -- an EIP-7702
type-4 artifact that delegates the signer's own EOA while matching every
displayed field was accepted before this change. The serialized bytes handed
to broadcastTransaction are compared against the parsed artifact, so the
guarantee covers the bytes that actually go to the node.

Signing failures in the popup are retryable again. To make that safe, an
approval is claimed synchronously before the first await and every path that
resolves or removes one goes through a single chokepoint that refuses a claimed
approval. Without it, closing the approval window, switching the active address
or a late reject would report "User rejected the request." to the dApp while
the broadcast completed -- the user then redoes the transfer at a fresh nonce
and it sends twice.

Failure copy distinguishes the stage reached, so a user is never told to start
again from the site when the first attempt may already have reached the network.
2026-08-12 11:21:02 +02:00
937f699fb1 feat: remove an address from an HD wallet, behind a confirmation (closes #162)
All checks were successful
check / check (push) Successful in 36s
Address rows on Home gain an [x] control, on wallets that derive addresses from
an extended key and hold more than one, opening a DeleteAddress confirmation
screen.

Removal cannot destroy anything: the key material stays. Derivation indices are
not renumbered, so the next "+" derives the next unused index rather than
resurrecting the removed one. The confirmation states the real route back --
delete the whole wallet in Settings, which asks for the password and destroys
the stored recovery phrase, then import it again -- and notes that the scan
which follows only finds addresses with on-chain activity. The copy varies by
wallet type, since an xprv wallet has no recovery phrase.

Removing an address that holds a balance is allowed, with a warning naming no
figure; the funds are at the address on-chain and stay there either way.
Selection and active address move only when the removed address was the one
selected, and site permissions are dropped for it alone.

The state transition shares its address comparison, permission cleanup and
active-changed broadcast with the wallet-level removal.
2026-08-12 11:16:29 +02:00
1f41a07df2 fix: filter a fake ETH token from the balance list too (closes #235)
All checks were successful
check / check (push) Successful in 30s
2026-08-12 11:10:38 +02:00
78a1cb067e test: commit a verify-build failure-mode battery and run it from make check (closes #227)
All checks were successful
check / check (push) Successful in 51s
2026-08-12 11:05:08 +02:00
afe6ddaea0 fix: WaitTx timeout no longer overwrites a rendered success screen (closes #155)
All checks were successful
check / check (push) Successful in 30s
2026-08-12 10:58:36 +02:00
23712b53cb fix: wipe the exported private key from the DOM on any view leave (closes #221)
All checks were successful
check / check (push) Successful in 29s
2026-08-12 10:54:37 +02:00
bd4bdcafc7 fix: explain a stored non-master xprv wallet instead of throwing at signing time (closes #234)
All checks were successful
check / check (push) Successful in 30s
2026-08-12 10:41:49 +02:00
ce4a0d7b8d fix: distinguish an unknown holder count from zero so a legitimate token is not filtered (closes #230)
All checks were successful
check / check (push) Successful in 39s
2026-08-12 10:34:45 +02:00
bf1dbec87c fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 26s
2026-08-12 10:30:15 +02:00
ba35282092 docs: describe the bundled token list by its criterion, not a drifting count (closes #239)
All checks were successful
check / check (push) Successful in 29s
2026-08-12 10:20:40 +02:00
158278d251 fix: count the network fee in the confirm-screen balance check (closes #154)
All checks were successful
check / check (push) Successful in 25s
2026-08-11 15:41:37 +02:00
6f6bc2e7b5 fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:38:28 +02:00
74c137dadf fix: add a Settings toggle for known-symbol spoof verification (closes #176)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:38:05 +02:00
edea22f7ed fix: move the UTC Timestamps checkbox into the Display well (closes #212)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:34:16 +02:00
b155c0fcd6 fix: enforce the base58 checksum and reject non-master extended keys (closes #210)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:31:50 +02:00
fb9e8f5542 fix: NUL-delimit verify-build's dist walk so no path escapes the check (closes #223)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:26:43 +02:00
3e5d6323ce feat: password-gated recovery phrase display for HD wallets (closes #161)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:25:17 +02:00
12acf4dc8c fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:16:48 +02:00
f455b0ae7f test: known-answer coverage for HD derivation and the vault (closes #159)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:06:35 +02:00
86cdea5e4e chore: repo policy compliance sweep — test rerun, frozen lockfile, documented targets (closes #166)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:57:51 +02:00
f271bcd7b4 fix: one transaction history row per value movement (closes #177)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:56:30 +02:00
93e3f6e4e2 fix: correct verify-build diagnostics and close two robustness gaps (closes #180)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:55:06 +02:00
9b957ffd69 fix: derive hasWallet from the wallet list on load (closes #195)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:51:22 +02:00
cf5f582be9 docs: correct three README claims contradicted by the code (closes #213)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:41:16 +02:00
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
6aeab54e8c Merge pull request 'fix: correct swap display — output token, min received, from address, and amount' (#128) from fix/issue-127-swap-amount-display into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #128
2026-03-01 16:19:41 +01:00
f65764d501 Merge branch 'main' into fix/issue-127-swap-amount-display
All checks were successful
check / check (push) Successful in 21s
2026-03-01 16:19:26 +01:00
4e097c1e32 Merge pull request 'feat: add Type field and on-chain details to transaction detail view' (#130) from fix/issue-95-transaction-type-display into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #130
2026-03-01 15:46:33 +01:00
clawbot
3f6f98dcaf docs: update TransactionDetail screen map with new fields
All checks were successful
check / check (push) Successful in 22s
Add Type, Token contract, Block, Nonce, Transaction fee, Gas price,
and Gas used fields to the TransactionDetail section in the README
screen map to match the implemented UI.
2026-03-01 06:17:40 -08:00
user
3e900dc14c feat: add Type field and on-chain details to transaction detail view
All checks were successful
check / check (push) Successful in 9s
- Always display a Type field as the first item under the Transaction
  heading, identifying the transaction as: Native ETH Transfer, ERC-20
  Token Transfer, Swap, Token Approval, Contract Call, or Contract Creation
- Show token contract address with identicon for ERC-20 transfers
- Fetch and display on-chain details from Blockscout: block number,
  nonce, transaction fee, gas price, and gas used
- All new fields are click-copyable with Etherscan links where applicable

closes #95
2026-03-01 06:11:35 -08:00
user
5dfc6e332b fix: correct swap display — output token, min received, from address, and amount
All checks were successful
check / check (push) Successful in 23s
Fix multi-step Uniswap swap decoding and transaction display:

1. uniswap.js: In multi-step swaps (e.g. V3 → V4), the output token and
   min received amount now come from the LAST swap step instead of the
   first. Previously, an intermediate token's amountOutMin (18 decimals)
   was formatted with the final token's decimals (6), producing
   astronomically wrong 'Min. received' values (~2 trillion USDC).

2. transactions.js: Contract call token transfers (swaps) are now
   consolidated into the original transaction entry instead of creating
   separate entries per token transfer. This prevents intermediate hop
   tokens (e.g. USDS in a USDT→USDS→USDC route) from appearing as the
   transaction's Amount. The received token (swap output) is preferred.

3. transactions.js: The original transaction's from/to addresses are
   preserved for contract calls, so the user sees their own address
   instead of a router or Permit2 contract address.

closes #127
2026-03-01 05:52:10 -08:00
a182aa534b Merge pull request 'fix: include timezone offset in all displayed timestamps (closes #116)' (#120) from fix/issue-116-timestamp-timezone into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #120
2026-03-01 13:36:04 +01:00
a388100262 Merge branch 'main' into fix/issue-116-timestamp-timezone
All checks were successful
check / check (push) Successful in 22s
2026-03-01 13:35:26 +01:00
dd3cabf816 Merge pull request 'feat: add theme setting (Light/Dark/System) with dark mode — closes #125' (#126) from feat/issue-125-dark-mode into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #126
2026-03-01 13:35:11 +01:00
user
235e5e7fa7 fix: improve dark mode contrast for hover, well, section, and border colors
All checks were successful
check / check (push) Successful in 22s
2026-03-01 03:49:18 -08:00
user
be06bd8f0c fix: improve dark mode contrast for wells and balance display
All checks were successful
check / check (push) Successful in 10s
- Change dark mode --color-well from #0a0a0a to #111111 for visible
  contrast against #000000 background
- Add explicit text-fg class to balance display element to ensure
  white text in dark mode
2026-03-01 03:38:27 -08:00
user
a72359432b fix: include timezone offset in all displayed timestamps
All checks were successful
check / check (push) Successful in 21s
All isoDate() functions now output proper ISO 8601 format with timezone
offset (e.g. 2026-02-28T15:30:00-08:00) instead of bare datetime strings.
Also uses 'T' separator per ISO 8601.

closes #116
2026-03-01 03:36:49 -08:00
user
2bdb547995 feat: add theme setting (Light/Dark/System) with dark mode
Add theme preference (light/dark/system) stored in extension state.
System mode follows prefers-color-scheme and listens for changes.
Dark mode inverts the monochrome palette (white-on-black).
Theme selector added to Display section in settings.

Closes #125
2026-03-01 03:36:42 -08:00
834228b572 Merge pull request 'fix: reserve space for all error/status messages — closes #123' (#124) from fix/issue-123-layout-shift-audit into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #124
2026-03-01 12:33:08 +01:00
clawbot
813993f17c fix: reserve space for all error/status messages — closes #123
All checks were successful
check / check (push) Successful in 22s
Replace display:none (hidden class) with visibility:hidden/visible for all
error, warning, and status message elements across the extension UI. This
prevents layout shift when messages appear or disappear.

Changes:
- helpers.js: showError/hideError now use visibility instead of hidden class
- index.html: all error/status divs use visibility:hidden + min-height
- confirmTx.js: warnings, errors, fee section use visibility
- approval.js: tx-error, sign-error, danger-warning use visibility
- addressDetail.js: export-privkey-flash uses visibility
- deleteWallet.js: delete-wallet-flash uses visibility
- addWallet.js: phrase-warning uses visibility
- receive.js: erc20-warning uses visibility
- addToken.js: add-token-info uses visibility
- settingsAddToken.js: settings-addtoken-info uses visibility
2026-02-28 16:30:43 -08:00
5f01d9f111 Merge pull request 'feat: speed up copy-flash timing by ~25% — follow-up to #113' (#121) from fix/issue-100-faster-copy-flash into main
All checks were successful
check / check (push) Successful in 11s
Reviewed-on: #121
2026-03-01 01:21:24 +01:00
user
d78af3ec80 feat: speed up copy-flash timing by ~25%
All checks were successful
check / check (push) Successful in 20s
Reduce active phase from 100ms to 75ms, fade transition from 300ms to
225ms, and cleanup delay from 350ms to 275ms for snappier feedback.

Refs #100
2026-02-28 16:17:07 -08:00
753fb5658a Merge pull request 'fix: cross-wallet-type duplicate detection — closes #111' (#115) from fix/issue-111-cross-wallet-dedup into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #115
2026-03-01 01:13:15 +01:00
bdb2031d46 Merge branch 'main' into fix/issue-111-cross-wallet-dedup
All checks were successful
check / check (push) Successful in 21s
2026-03-01 01:13:06 +01:00
25ecaee128 Merge pull request 'feat: add copy-flash visual feedback — closes #100' (#113) from fix/issue-100-copy-flash-feedback into main
All checks were successful
check / check (push) Successful in 21s
Reviewed-on: #113
2026-03-01 01:12:40 +01:00
user
ff4b5ee24d feat: add copy-flash visual feedback on click-to-copy
All checks were successful
check / check (push) Successful in 9s
When a user clicks to copy text (addresses, tx hashes, etc.), the copied
element now briefly flashes with inverted colors (bg/fg swap) and fades
back over ~300ms. This provides localized visual feedback in addition to
the existing flash message.

Applied to all click-to-copy elements across all views.

closes #100
2026-03-01 01:01:34 +01:00
user
ca6e9054f9 fix: cross-wallet-type duplicate detection for all import methods
All checks were successful
check / check (push) Successful in 22s
- Private key import now checks ALL wallets (hd, xprv, key) for address conflicts
- xprv import now checks xpub against existing xpubs and addresses across all wallet types
- Mnemonic import now checks xpub against xprv wallets and addresses across all types
- Extract findWalletByAddress() and findWalletByXpub() helpers for consistent dedup

closes #111
2026-02-28 15:58:47 -08:00
09c52b2519 Merge pull request 'feat: show red warning when sending to address with zero tx history' (#98) from issue-82-zero-tx-warning into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #98
2026-03-01 00:54:15 +01:00
1fb9fade51 Merge branch 'main' into issue-82-zero-tx-warning
All checks were successful
check / check (push) Successful in 22s
2026-03-01 00:53:45 +01:00
bc04482fb5 Merge pull request 'feat: add xprv wallet import support' (#53) from feature/import-xprv into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #53
2026-03-01 00:53:08 +01:00
user
045328f3b9 fix: use visibility:hidden/visible instead of CSS transitions for zero-tx warning
All checks were successful
check / check (push) Successful in 22s
Remove all CSS transitions, max-height changes, and opacity animations.
The warning container always reserves its space with visibility:hidden
and switches to visibility:visible when needed. No layout shift ever.
2026-02-28 15:46:58 -08:00
user
576fe3ab15 fix: replace visibility:hidden with smooth collapse for zero-tx warning
All checks were successful
check / check (push) Successful in 10s
Instead of permanently reserving space with visibility:hidden, the warning
container now uses max-height + opacity transitions. Space is reserved during
the async check, then smoothly collapses to 0 if the warning isn't needed.
This reclaims ~40px of popup viewport in the common case.
2026-02-28 15:37:27 -08:00
user
35bb6b9806 fix: add hover classes to all inactive tabs in switchMode()
All checks were successful
check / check (push) Successful in 22s
The 'From Phrase' tab was missing hover:bg-fg and hover:text-bg classes
when transitioning from active to inactive state. switchMode() now
explicitly toggles these hover classes on all tabs, ensuring identical
hover behavior across all three inactive tabs.
2026-02-28 15:33:26 -08:00
user
e56e15e34c style: tabby tab styling with dashed inactive borders and hover invert
- Active tab: solid border on top/sides, bottom border matches background
  (connects to content area), bold text
- Inactive tabs: dashed borders in border-light color, muted text,
  transparent bottom border
- Inactive hover: invert (bg-fg text-bg) for clear clickability signal
- All three tabs behave identically on hover
2026-02-28 15:33:26 -08:00
user
cc69ce39ed fix tabs: use underline tab style with hover on all tabs
Tabs are not buttons (they change UI state, not application state).
All tabs now use underline style with identical hover behavior:
- Active: bold text + solid bottom border
- Inactive: muted text + transparent bottom border
- Hover (all tabs): text brightens to fg + bottom border appears
This ensures all tabs clearly indicate clickability on hover,
including the currently active one.
2026-02-28 15:33:26 -08:00
user
9476724284 fix tab affordance: use standard button style with border + hover invert
Per README clickable affordance policy: all tabs now use visible
border, padding, and hover:bg-fg hover:text-bg (invert to
white-on-black). Active tab is inverted (bg-fg text-bg). All
three tabs behave identically on hover regardless of active state.
2026-02-28 15:33:26 -08:00
user
9246959777 fix: tab labels add (xprv) suffix, restyle tabs as underline view switcher
- Rename 'From Extended Key' to 'From Extended Key (xprv)'
- Replace box-border tab style with underline indicator pattern
- Active tab: bold text + solid bottom border
- Inactive tabs: muted text + transparent bottom border with hover state
- Tabs now clearly read as mutually-exclusive view switchers, not buttons
2026-02-28 15:33:26 -08:00
user
0f6daf3200 restyle tabs as classic tab bar with connected active tab 2026-02-28 15:33:26 -08:00
user
435669b6b6 fix: use full tab labels and add hover border for tab affordance
- 'From Phrase' → 'From Recovery Phrase'
- 'From Key' → 'From Private Key'
- 'From xprv' → 'From Extended Key'
- Add hover:border-fg to inactive tabs for visible hover affordance
2026-02-28 15:33:26 -08:00
user
f75a258125 restyle add-wallet tabs: 'From' prefix, underline tab style
- Tab labels: 'From Phrase', 'From Key', 'From xprv'
- Visual: bottom-border underline on active tab (not filled buttons)
- Inactive tabs: muted text with hover highlight
- Container: bottom border connects tabs to content area
2026-02-28 15:33:26 -08:00
user
4d120e5ea9 refactor: unify add-wallet, import-key, and import-xprv into single view
Merge all three wallet import methods (recovery phrase, private key,
extended key/xprv) into one tabbed add-wallet view with a mode selector.
This fixes the blank import-xprv render (it was missing from the VIEWS
array) and the broken back-button navigation from the separate import
views.

- Add tab selector: Recovery Phrase | Private Key | Extended Key (xprv)
- Share password fields across all modes
- Remove separate import-key and import-xprv views and modules
- Add duplicate wallet detection for private key imports
- All tabs follow affordance policy (visible border + hover state)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:33:26 -08:00
user
57959b70c3 fix: derive xprv addresses from correct BIP44 path (m/44'/60'/0'/0)
hdWalletFromXprv() and getSignerForAddress() for xprv type were deriving
addresses directly from the root key (m/N) instead of the standard BIP44
Ethereum path (m/44'/60'/0'/0/N). This caused imported xprv wallets to
generate completely wrong addresses.

Navigate to the BIP44 Ethereum derivation path before deriving child
addresses, matching the behavior of mnemonic-based wallet imports.
2026-02-28 15:33:26 -08:00
user
7a7f9c5135 feat: add xprv wallet import support
Add the ability to import an existing HD wallet using an extended
private key (xprv) instead of a mnemonic phrase.

- New 'xprv' wallet type with full HD derivation and address scanning
- New importXprv view with password encryption
- Updated getSignerForAddress to handle xprv wallet type
- Added xprv link to the add-wallet view
- Allow adding derived addresses for xprv wallets

Closes #20
2026-02-28 15:33:26 -08:00
user
8c071ae508 fix: never collapse warning container — always reserve space to prevent layout shift
All checks were successful
check / check (push) Successful in 10s
Replace display:none with persistent visibility:hidden so the warning
area occupies the same vertical space regardless of API result.
This eliminates the layout shift that occurred when the container was
collapsed after the recipient history check returned.
2026-02-28 15:26:49 -08:00
user
a3c2b8227a fix: zero-tx warning layout shift and contract address false positive
- Reserve space for the warning upfront using visibility:hidden instead
  of display:none, preventing layout shift per README policy
- Move warning HTML to index.html as a static element rather than
  injecting dynamically
- Skip warning for contract addresses (check getCode first) since
  getTransactionCount only returns outgoing tx nonce
- Collapse reserved space when warning is not needed (address has
  history, is a contract, or on RPC error)
2026-02-28 15:26:44 -08:00
user
f9f3e7b85a feat: show red warning when sending to address with zero tx history
On the confirm-tx view, asynchronously check the recipient address
transaction count via getTransactionCount(). If zero, display a
prominent red warning advising the user to double-check the address.

Closes #82
2026-02-28 15:26:44 -08:00
812fc01a98 Merge pull request 'feat: add etherscan link and click-to-copy on block number in success-tx view' (#102) from issue-99-block-number-link into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #102
2026-03-01 00:23:07 +01:00
user
811c125cb9 fix: remove click-to-copy from timestamps in list views
All checks were successful
check / check (push) Successful in 22s
List view rows (home, addressDetail, addressToken) should only be clickable
as a whole to navigate to the detail view. Click-to-copy on individual
elements belongs only in the transaction detail view.

Reverts timestamp click-to-copy changes in list views per review feedback.
Keeps blockNumberHtml() and detail-view timestamp changes.
2026-02-28 15:21:13 -08:00
user
3005813f2c feat: add click-to-copy on timestamps in all transaction list views
All checks were successful
check / check (push) Successful in 9s
Adds click-to-copy (copies ISO date string) to timestamp displays in:
- home view (relative time ago)
- addressDetail view (relative time ago)
- addressToken view (relative time ago)
- transactionDetail view (full ISO date)

All timestamps now show dashed underline to indicate copyability,
matching the existing UX pattern for addresses, tx hashes, and
block numbers.
2026-02-28 14:40:11 -08:00
user
5565e76796 feat: add etherscan link and click-to-copy on block number in success-tx view
All checks were successful
check / check (push) Successful in 22s
Block numbers are blockchain entities like addresses and tx hashes. They now
receive the same treatment: click-to-copy and an external link icon pointing
to etherscan.io/block/{number}.

Closes #99
2026-02-28 14:09:23 -08:00
132 changed files with 33482 additions and 2053 deletions

View File

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

View File

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

49
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,49 @@
name: e2e
on: [push]
# The browser end-to-end suites, one job per browser, deliberately kept out
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
# script/cibuild is a plain `docker build .` whose Dockerfile runs
# make check, so folding a browser suite into either would blow that cap
# and slow the local fast path. Before this workflow every browser-level
# guarantee in this repo held only when a human remembered to run it.
#
# One job per browser rather than two steps in one job, so a Chrome failure
# does not hide the Firefox result.
#
# Each job is one script and nothing else. Both scripts need docker and
# nothing else — they deliver the repo to the daemon as a build context and
# build the extension inside the pinned image — which is what makes them
# runnable here at all: the runner executes the job in a container against
# the host's docker socket, so a `-v "$PWD:/work"` source path is resolved
# by the host daemon and mounts an empty directory, and the runner image's
# node is too old to install this repo's dependencies.
#
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked on the measured
# flake in the dApp signing wait -- two of six runs of unmutated code on a
# loaded machine -- tracked as
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
#
# Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the
# image build fails, and when the browser fails to start; the Chrome
# harness aborts the suite outright if its network interception is not in
# effect.
jobs:
e2e-chrome:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/test-e2e
e2e-firefox:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/test-e2e-firefox

View File

@@ -1,15 +1,42 @@
# node:22-slim (22.x LTS), 2026-02-24
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
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
WORKDIR /app
# Marks "already inside the lint container" for script/lint, which otherwise
# shells out to docker to build the lint stage below. Nothing outside this
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache
# and shares the runner with the rest of the build, so 30s is marginal rather
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180
# 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 ./
RUN yarn install --frozen-lockfile
RUN script/bootstrap
COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests
# and the build. This is also the stage script/lint builds from a host, which
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint
RUN make lint
# Full check and build. The COPY --from is a no-op file copy whose only job is
# to make BuildKit finish the lint stage before this one starts; without it the
# stages run in parallel and a lint failure would not fail the build early.
FROM base AS check
COPY --from=lint /app/package.json /dev/null
RUN make check
RUN make build

114
LICENSE
View File

@@ -672,3 +672,117 @@ 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
Public License instead of this License. But first, please read
<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: the eth-phishing-detect community blocklist (src/config.json).
The file here is derived from it, not a copy of it: only the
blacklist is carried over, and each entry is stored as a truncated
digest rather than a domain name. script/vendor-blocklist records
the exact upstream URL, the commit it is pinned to and the hash of
the bytes that commit serves, and is what regenerates this file.
The URL previously cited here, under a different organisation,
returns 404: that repository is gone.
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,77 @@
.PHONY: install test lint fmt fmt-check check docker hooks build clean dev
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug verify-build vendor-blocklist 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:
@yarn install
@yarn install --frozen-lockfile
test:
@echo "Running tests..."
@timeout 30 yarn run test 2>&1
@script/test
# Browser end-to-end suites. Both require docker; neither is part of check.
test-e2e:
@script/test-e2e
test-e2e-firefox:
@script/test-e2e-firefox
lint:
@echo "Linting..."
@yarn run lint 2>&1
@script/lint
fmt:
@echo "Formatting..."
@yarn run fmt 2>&1
@script/fmt
fmt-check:
@echo "Checking formatting..."
@yarn run fmt-check 2>&1
@script/fmt-check
check: test lint fmt-check
check:
@script/check
# Assert that the competitor name appears nowhere but its documented
# exceptions. Part of check, and re-run against dist/ at the end of a build;
# separate target for re-running it alone.
check-censored:
@script/check-censored
docker:
@script/docker
hooks:
@script/install-precommit
build:
@echo "Building extension..."
@yarn run build 2>&1
@script/verify-build
@script/check-censored --require-dist
# 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
@script/check-censored --require-dist
# 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
# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
# Run deliberately, land the diff: the extension does no runtime fetching, so
# the shipped list is as fresh as the last vendoring run that was released.
vendor-blocklist:
@script/vendor-blocklist
clean:
@rm -rf dist/
@@ -31,15 +79,3 @@ clean:
dev:
@echo "Building in watch mode..."
@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."

1308
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-02-22
last_modified: 2026-07-06
---
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 repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model 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.)
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
repos, the Dockerfile should bring up a development environment and run
`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
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
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,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
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
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
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.
- `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
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 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
`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:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **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?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
@@ -144,8 +361,14 @@ style conventions are in separate documents:
- Use SemVer.
- 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).
Post-1.0.0: add new migration files.
the binary.
- `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
settings.
@@ -175,6 +398,9 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`

585
TODO.md Normal file
View File

@@ -0,0 +1,585 @@
# 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.
Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
of them on every push.
# Next Step
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.
# Completed Steps
- 2026-08-17: The Settings screen is driven in a browser, and every element id
the popup looks up is checked statically. Nothing exercised Settings in the
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups
in the codebase was unverified at runtime. Seven new cases in
`tests/e2e/run.js` reach Settings, assert the About well and the wallet list
were actually written, assert the four Token Spam Protection checkboxes are
real checkboxes defaulted on, and assert the theme and network selectors offer
the choices `src/shared/networks.js` and `index.html` define. The selectors
are then driven to `dark` and `sepolia` — neither is the first `<option>`, so
neither can be read back from the markup with no JavaScript involved — and
reasserted after a popup reopen before being restored the same way, and one
spam filter is toggled off and back on across a reopen each way. Those round
trips run the change handler, `saveState()`, `loadState()` and the
`init()`/`show()` assignments rather than just looking at the screen. `show()`
no longer guards its `settings-network` lookup with `if (networkSelect)`: a
missing element must fail loudly, which is the whole failure mode this unit
exists to catch. Each group records a coverage key and a final case demands
the exact set, so a shortened or skipped section reddens the run instead of
shrinking it. `tests/popupElementIds.test.js` is the general half and runs in
`make check` with no browser: every literal id reached through `$()`,
`document.getElementById()`, `showError()`/`hideError()` and `showView()` must
exist in `src/popup/index.html`, which no id in `index.html` may define twice.
Demonstrated on four deliberate breaks — a typo'd id (both halves red), a
handler bound to the wrong but existing element (only the functional e2e case
red), a typo in a view no browser suite opens (only the static guard red), and
the deletion of both persisted-value assignments in `settings.js` (only the
selector round-trip case red)
([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)).
- 2026-08-17: The phishing blocklist is vendored at build time and censored, and
the runtime fetch is gone
([#219](https://git.eeqj.de/sneak/AutistMask/issues/219)).
`script/vendor-blocklist` fetches upstream at a pinned commit, verifies the
sha256 of the bytes it was served, and writes
`src/shared/phishingBlocklist.json` as truncated sha256 digests rather than
domain names — which is what removes the competitor's name from a list that
carried it 6,475 times, without dropping a single one of those domains.
`script/check-censored` runs in `make check` and again against `dist/` at the
end of every build, each permitted occurrence scoped to the one path allowed
to carry it; the name now appears only in the vendoring script, which defines
it once, in the provider-shim identifiers in `src/content/inpage.js`, and in
one ERC-20's on-chain name in `src/shared/tokenList.js`. Removing the fetch
retired the delta, the persistence and the 24-hour alarm from
[#158](https://git.eeqj.de/sneak/AutistMask/issues/158), and retired alarms
are now cleared rather than left running on existing installs. Two
consequences, both deliberate: the list no longer self-updates, so it is as
fresh as the last vendoring run that was released; and re-vendoring from
current upstream took it from 231,357 stale entries to 105,721 current ones,
because upstream prunes and the vendored snapshot never did. `dist/` fell from
18.9 MB to 8.9 MB. The e2e suite now drives the warning end to end from a real
blocklisted origin, and its service-worker interception canary has a new
anchor, because the startup fetch it used to watch for no longer exists.
- 2026-08-17: One wording for an empty password field on every screen that asks
for one. The private key export screen said "Password is required." where the
other five say "Please enter your password.", the same one-condition-two-
wordings split that [#172](https://git.eeqj.de/sneak/AutistMask/issues/172)
closed for a rejected password. Strings only, no behaviour change.
`tests/passwordMessages.test.js` now pins the empty-field guard per call site
as well as the decrypt handler, anchored on the `decryptWithPassword` sites so
the wallet-creation screen — where an empty field means a password being
chosen, a different condition — stays out of the set. Every error container
measured at a 360px viewport in the pinned Playwright container: the export
screen's container holds at 20px with the following section at the same offset
for the old string, the new string and the empty reserved state
([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)).
- 2026-08-17: One shared extension-API module,
[`src/shared/browserApi.js`](src/shared/browserApi.js), is the only place in
the tree that names `browser` or `chrome`. Every call site returns a promise;
`runtime.lastError` is gone. The same commit gives the Firefox suite the four
dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction` and a closed approval window rejecting with EIP-1193
4001 — against a page and a JSON-RPC node served from loopback, which survives
`--network none`. **The premise of
[#153](https://git.eeqj.de/sneak/AutistMask/issues/153) does not survive that
harness**: Firefox's `browser.*` honours a trailing Chrome-style callback and
populates `runtime.lastError`, both measured directly on Firefox 153.0.3, and
all four flows pass against the unconverted code. What landed is a uniformity
and coverage change, not a repair of a broken target. `storageGet()` and
`storageSet()` **reject** where `storage.local` is absent rather than
resolving `{}` and a no-op write — they carry the wallet, and defaulting would
read an existing wallet back as none. The one caller that genuinely degraded,
[`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), took
`storageLocal()` directly and kept its own null check; it stores nothing at
all as of [#219](https://git.eeqj.de/sneak/AutistMask/issues/219) above.
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
price. Prices exist for the top 25 tokens only, so the priced-only sum was
printed as the total and an address holding nothing but unpriced ERC-20s was
shown as worth nothing — directly under "This address holds a balance." on the
address-removal confirmation. `getAddressValue()` in `src/shared/prices.js`
now returns `{ usd, partial }`, keeping worth-zero and worth-an-unknown-amount
apart the way an absent `holders_count` is kept apart from a count of zero,
and every screen renders it through the one `formatAddressTotal()`: the figure
when it covers everything, the figure marked `plus unpriced tokens` when it
covers part, and `Total: unpriced tokens only` when it would cover nothing.
Home, AddressDetail and the removal confirmation all read it, and
`getWalletValue()`/`getTotalValue()` carry `partial` up. Covered by
`tests/addressValue.test.js` — the only-unpriced, genuinely-zero and
fully-priced cases at the helper and at both call sites that return their
markup — demonstrated failing first
([#261](https://git.eeqj.de/sneak/AutistMask/issues/261)).
- 2026-08-17: `README.md` no longer advertises a defect the wallet does not
have. The End-to-End Tests section listed the EIP-1193 code being dropped in
the last hop into the page as a standing limit of the dApp coverage; that
stopped being true when
[#274](https://git.eeqj.de/sneak/AutistMask/issues/274) landed and did not
touch the README. The paragraph is deleted and the two remaining limits — the
stubbed RPC and the unobservable toolbar popup — were checked against the
current `src/content/inpage.js` and `tests/e2e/` and left as they are
([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)).
- 2026-08-17: One transaction approval at a time. Populating in the background
before the window opens is what makes the displayed object the verified
object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
concurrently took the same nonce from a node that had seen neither broadcast,
and the second could then never be sent, because the only way to give it a
fresh nonce is to populate it again after the user has read the old one off
the screen. A second request is now refused with EIP-1193 `-32002` while one
is unanswered — the slot is taken immediately before population, after the
authorization checks, so no second nonce is allocated, no second window opens,
and a page the wallet refuses anyway cannot hold the slot against the
connected site. The slot is freed at `settleApproval()`, the single point an
approval is retired, so every path that ends an approval ends the hold with
it; an approval whose window is gone and whose attempt has failed is settled
there rather than left waiting on a window that no longer exists, and an
approval window that could not be opened at all is answered with `-32603`
instead of holding the page's promise open. Signature approvals are not gated,
consuming no nonce. A collision that does happen is also reported accurately
now: a broadcast the node refused for the nonce, and an approval carrying a
nonce this worker has already broadcast for that address on that chain (caught
before the node is asked at all), both say the transaction did not reach the
network and to send it again, instead of warning that it may have sent. The
record is keyed by chain as well as address, because nonce spaces are per
chain and low nonces overlap across them. `already known` deliberately keeps
the ambiguous wording, because a node that says it has the transaction has it
([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
- 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
e2e suite did not cover are asserted. It had only shown that the two screens
open without throwing. Now: the Add Token round trip leaves the navigation
stack exactly as it found it, read out of extension storage rather than
inferred from which screen is up, so an orphaned entry — the second-order
damage of #150 — is caught where it happens rather than one Back press later;
a common-token quick-pick puts its contract address in the field; the native
ETH detail path renders with its own type, value and raw quantity and with the
token contract row still hidden, against a new `seedNativeTransfer` fixture,
since the normal-transactions endpoint answered `[]` unconditionally and there
was no non-ERC-20 row to open; and tapping the token contract address puts it
on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-14: `make check` does static analysis. `script/lint` ran
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
two shipped used-but-not-imported crashes behind it was green. ESLint is now
pinned in `package.json` with `@eslint/js` recommended as the base, flat
config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
globals declared per tree — browser for the popup and content scripts, service
worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
`build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
`make lint`. Linting is also containerized now: `script/lint` builds the
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
green is the pinned one and not the host's. The lint stage roughly doubles the
image build, so `script/test`'s hard timeout is now a bound on a hung suite
rather than a wall-clock budget: 30s on the host, where the suite runs in
about 8s, and `AUTISTMASK_TEST_TIMEOUT` raises it inside the image, where a
cold build pays install and contention costs the policy budget never described
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 2026-08-14: CI runs the browser end-to-end suites. `.gitea/workflows/e2e.yml`
runs `script/test-e2e` and `script/test-e2e-firefox` as two jobs on every
push, separate from `check`, so `make check` and its 20-second `make test` cap
are untouched. Every browser-level guarantee in this repo — the WASM-under-CSP
check, the recovery-phrase and private-key DOM wipes, the ConfirmTx spend
gate, the dApp approval round trips — was enforced only when a human
remembered to run it by hand. The suites could not run on the runner as they
stood: the runner executes a job in a container against the host's docker
daemon, so `docker run -v "$PWD:/work"` mounts an empty directory (measured),
and the runner image's node cannot install this repo's dependencies. Both
suites now ship the repo to the daemon as a build context and build the
extension inside the pinned image, so docker is the only prerequisite on a
runner or a laptop, and both run the image by ID rather than by tag so
concurrent clones cannot swap it. The jobs report rather than gate — this repo
configures no branch protection, and the Chrome suite is measurably flaky
under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
rather than papered over
([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
- 2026-08-14: A background message handler that throws now rejects the page
instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
and `sendResponse` is the only thing that settles the dApp's
`window.ethereum.request()` promise — so any throw inside `handleRpc` left
that promise pending forever, with no error and no timeout, indistinguishable
from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
describes "the wallet broke" and none was invented) and logs the method and
the throw to the background console rather than swallowing them. The two async
IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
same shape one level down — every statement inside a `try`, but a throw out of
a `catch` block escaping unhandled — and each got a last-resort `.catch()`
settling the approval through `settleApproval()` and answering the popup. The
transaction one tracks which phase it escaped from and reports that, so an
escape before `broadcastTransaction()` says the request is gone rather than
that it may still have reached the network. Every other handler on the path is
synchronous. All four are driven by real failures — a rejecting storage read,
and a failure classifier that throws while classifying a genuine verification
or broadcast failure — and were demonstrated failing first, the RPC one with
`sendResponse` at zero calls
([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
- 2026-08-14: Approving a site connection is no longer a race against the popup
closing. The decision now rides the approval port the popup already holds,
which is the same channel the close disconnects, so it is delivered ahead of
that disconnect however fast the teardown is; `windows.onRemoved` no longer
decides a site approval whose port is connected, since that event is ordered
against nothing either. Rejecting and closing without deciding both still
report a rejection, and the popup delays its own close by nothing. The e2e
harness's deferred-`window.close()` accommodation is gone with it, so the two
site-prompt tests now drive the shipped decide-then-close in a real Chromium;
against the unfixed code the approval came back to the page as
`{"settled":"rejected","code":4001}`
([#275](https://git.eeqj.de/sneak/AutistMask/issues/275)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
the user deliberately declined was indistinguishable from one that broke. The
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
boundary sent one, `data`, passed through verbatim rather than matched against
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
here. An error the background sent with no code stays a plain `Error` with no
`code` property, and `message` is unchanged in every case. All four request
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
now requires it on the page's Error as well as on the wire, for all four
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
- 2026-08-12: "Back" now renders the screen it lands on instead of only unhiding
it. A reopened popup renders the wallet list and the one screen it restores
onto, so every screen further down the stack was still the blank template from
`index.html`, and Back walked straight onto it — an empty address, no
balances, no QR code. The Back path now goes through the same per-view
dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared
with `restoreView()`), falling back to Home when the state the target would
render is gone. It renders only a view this page load has not rendered yet:
`viewRouter.js` records every view that reaches `showView()`, which is where
forward navigation and the restore both end, so Back onto a view already on
the page unhides it and nothing more. That is what keeps a second render from
re-fetching and overwriting what the view holds — an unsaved edit in Settings,
a transaction list already loaded. Home is the exception and is always
re-rendered, as it was before. Covered by unit tests on the real `goBack()`
and by three end-to-end cases against the real popup, each demonstrated
failing on the unfixed build
([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
that bear it, not to one of them. A ticker is not unique: seven of the 512
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC`
share a symbol with another bundled entry at a different real contract, and
the table, built from the list first-wins, kept only the earlier one. The
other seven were judged spoofs of their own symbol at their own address and
hidden from the balance list, the history and the send selector, so a holder
could not spend them. Both contracts of each pair come from the same CoinGecko
fetch of 2026-02-27, so neither was stale and neither was dropped.
`isSpoofedSymbol()` asks set membership instead of equality, which does not
loosen the rule — a contract outside the set is still a spoof — and a test now
walks `TOKENS` asserting no bundled token is filtered at its own address,
which is the walk the suite lacked
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
- 2026-08-12: The dApp approval round trips are driven end to end in the
browser. A test page served by the harness speaks EIP-1193 to the real inpage
provider through the real content script, background worker and approval popup
for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction`. Every signature is recovered and compared against the
active address, the transaction is checked against the bytes handed to the
stubbed RPC, each rejection must reach the page as a rejection, and the
password must appear in no message the approval window sends — the assertion
that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
floor. This does not discharge a real dApp with real funds against mainnet
([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
character that paints nothing removed (the format and default-ignorable
characters, plus U+007F), then trimmed — so `" ETH "`, a no-break space, a
zero-width space, a Hangul filler, a variation selector, a DELETE and a
fullwidth `` are all caught on the balance list, the history and the
send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
bidi reordering and the visible C0/C1 controls — which measure 48.00px, a box,
in the pinned e2e Chromium where an invisible prefix measures 32.00px — stay
knowingly open and are asserted as open in the suite. No bundled symbol
contains whitespace or a non-ASCII character, so nothing legitimate is newly
filtered; the balance list's token-type gate also became case-insensitive,
which no longer drops a real holding if an explorer writes `erc-20`
([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
- 2026-08-12: A containerized Firefox end-to-end harness
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
build installed as a temporary add-on. Zero npm dependencies — a WebDriver
client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
privileged console service in Marionette's chrome context, because BiDi
`log.entryAdded` reports nothing at all for extension pages; each drain reads
and clears the console in one chrome round trip, so no error is destroyed
unread by the drain itself, and errors logged during add-on install and
background startup are folded into step 1 instead of being cleared. The two
measured limits are documented rather than claimed away: the console ring
buffer holds 250 messages (a clean run peaks at 4), and the drained window
ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting
1 on a `throw` at the top of `src/background/index.js`, on a build with one
import removed, on a `setTimeout` throw whose UI assertions all pass, on an
unhandled `Promise.reject` and on an undefined identifier in `home.js`, and 0
on the branch as it stands
([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
- 2026-08-12: The transaction a dApp asks for is now populated in the background
before the approval window opens, so the object the user is shown is the
object the signed artifact is verified against — nonce, gas limit and every
fee field are compared exactly instead of being left to the ceilings, which
stay as a backstop against what a lying RPC node can talk the wallet into
displaying. The approval also pins the address it was raised for, so an
address switch between approval and signing refuses rather than signing from
an account the screen never named, and a request naming an address that is not
the active one is refused outright. The approval screen now shows the fee, gas
limit, network and nonce it vouches for
([#216](https://git.eeqj.de/sneak/AutistMask/issues/216)).
- 2026-08-12: The restored navigation stack is filtered against
`RESTORABLE_VIEWS` on load, truncated at the first entry the popup would not
render so that every surviving entry keeps the Back target it had. Back after
reopening can no longer land on a view the popup declined to restore, such as
`export-privkey` or `show-phrase`
([#224](https://git.eeqj.de/sneak/AutistMask/issues/224)). Restorable views in
the stack are still unhidden without being re-rendered; that is tracked
separately in ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: One wording for a rejected password on every screen that asks for
one — the send confirmation and the delete-wallet confirmation no longer say
"Wrong password." (a fragment, which `RULES.md` Language & Labeling forbids)
and the two reveal screens no longer say "not correct", so all five
`decryptWithPassword` call sites now show the sentence the dApp approval paths
introduced. Strings only, no behaviour change, and each error container
measured at a 360px viewport in the pinned Playwright container
([#172](https://git.eeqj.de/sneak/AutistMask/issues/172)).
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
answered `200 []` instead of failing the suite; the guard now rejects an empty
batch, demonstrated green-before/red-after with a throwaway probe. The comment
claiming `postData()` returns `null` for undecodable bodies was corrected to
the two real paths — an absent or empty body decodes to `null`, a binary body
decodes lossily into invalid JSON
([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
- 2026-08-12: The transaction confirmation screen has browser coverage. The
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
off a funded-balance fixture, and asserts the pending, funded, over-balance
and estimate-failed states, the fee block quoting the estimate and the reserve
separately, and a constant view height across every one of those transitions.
The load-bearing assertion is that the spend gate reads the reserve and not
the displayed estimate: swapping the two fails the suite
([#238](https://git.eeqj.de/sneak/AutistMask/issues/238)).
- 2026-08-12: The dust threshold field now explains a rejection instead of
snapping back in silence, with the parse in a pure, unit-tested module that
accepts plain decimal digits only — hex and exponent notation are refused
rather than read as 16 and 1000
([#233](https://git.eeqj.de/sneak/AutistMask/issues/233)).
- 2026-08-12: Approval verification became an allowlist — transaction type
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
approved transfer, every consequential field compared, the artifact
re-serialized from the checked fields alone and its exact bytes required to be
the canonical encoding of what was broadcast. One approval now yields at most
one broadcast, and every path that retires a pending approval — popup close,
active-address change, a late reject — goes through a single chokepoint that
refuses to settle an attempt already claimed for signing and broadcast
([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
- 2026-08-12: An address can be removed from an HD or xprv wallet behind a
confirmation screen that states nothing is destroyed, sharing the deletion
state transitions with wallet deletion so the selection, site permissions and
active-address broadcast follow the same rules
([#162](https://git.eeqj.de/sneak/AutistMask/issues/162)).
- 2026-08-12: The known-symbol spoof rule moved into `src/shared/symbolSpoof.js`
and is now the only copy. The balance list had exempted symbols the token list
maps to `null``"ETH"` alone — so a fake ETH ERC-20 was hidden from the
transaction history and the Send selector but listed as a holding named ETH. A
symbol with no legitimate contract may now be borne by no contract on any of
the three surfaces, and the native exemption is "has no contract address", so
a second null-mapped symbol needs no call-site change. The user's real ETH
balance is read over RPC and never passes through the rule
([#235](https://git.eeqj.de/sneak/AutistMask/issues/235)).
- 2026-08-12: `script/verify-build`'s failure modes are now a committed target,
`script/test-verify-build`, run by `make check`. It asserts the exit status
and the message of every case against a fixture tree in a temp dir, and drops
privileges (proving the runner against a mode-000 file first) for the cases
that only mean something when file permissions are in force
([#227](https://git.eeqj.de/sneak/AutistMask/issues/227)).
- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no
longer both render on one tick, no timer or in-flight lookup outlives its
wait, a failed receipt lookup no longer counts as a timeout (but six in a row
end the wait, reported as an unreachable network rather than as a timeout),
and the wait now resumes after a popup close
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
- 2026-08-12: The private key export screen now wipes the key from the page
whenever it is left by any route, and a decrypt still in flight when the
screen is left is discarded instead of written; the same `onViewLeave()`
cleanup was extended to every other screen holding secret material in the DOM
(AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
- 2026-08-12: An xprv wallet already in storage that was imported from a
non-master key is detected from the depth of its stored `xpub`, explained in
the wallet list, and blocked from signing, sending and private-key export
instead of throwing on the send screen
([#234](https://git.eeqj.de/sneak/AutistMask/issues/234)).
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
`0`, so the low-holder rule declines to judge an unknown count instead of
hiding a legitimate token as spam, in both the transaction history and the
Send token selector ([#230](https://git.eeqj.de/sneak/AutistMask/issues/230)).
- 2026-08-12: Bundled token list documentation no longer states a count. The
four "top 250" claims in `README.md` and the "roughly 500" claim in
`docs/README.md` are replaced with a description of how the list is actually
selected — a point-in-time CoinGecko snapshot of the highest-market-cap
Ethereum mainnet ERC-20s — with `TOKENS` in `src/shared/tokenList.js` named as
the authoritative set
([#239](https://git.eeqj.de/sneak/AutistMask/issues/239)).
- 2026-08-11: libsodium runs on WebAssembly in the shipped builds —
`'wasm-unsafe-eval'` added to both manifest CSPs after measuring the wasm2js
fallback at 20x the Argon2id cost, pinned in both directions by
`tests/manifest.test.js` and observed in the real popup by the e2e suite
([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)).
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds
([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts
`dist/` is a real directory, so a path with a trailing space or a newline can
no longer carry a debug marker past the unlisted-bundle check
([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)).
- 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well
into Display, next to the theme selector
([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)).
- 2026-08-11: Network fee counted in the confirmation-screen balance check for
both ETH and ERC-20 sends, reserving what the node actually charges a type-2
transaction, with the arithmetic in a pure, unit-tested
`src/shared/txValidation.js`
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
falling back to the 100,000 gwei default, and every address comparison in
`src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
from the wallet row in Settings, wiped on leaving the screen and excluded from
the views the popup can reopen onto
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
- 2026-08-11: Extended-key import hardened — the base58 checksum is now enforced
on every xprv and xpub, and a non-master key is refused with an explanation
instead of being derived beneath
([#210](https://git.eeqj.de/sneak/AutistMask/issues/210)).
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
from `setInterval` to the extension alarms API, with the phishing delta and
its fetch timestamps persisted to extension storage, so neither job dies with
the MV3 service worker. Each job's freshness guard was decoupled from its
alarm period at the same time — timed to the period, a guard vetoes its own
scheduled tick and halves the real refresh rate
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
message now states what is and is not proven, an unreadable bundle is
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
derivation in `wallet.js` and the Argon2id vault in `vault.js`
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
a plain ERC-20 transfer absorbed into its token row
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
- 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.
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.

200
build.js
View File

@@ -3,75 +3,183 @@ const path = require("path");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const DIST_CHROME = path.join(__dirname, "dist", "chrome");
const DIST_FIREFOX = path.join(__dirname, "dist", "firefox");
const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src");
// The module whose compiled DEBUG state script/verify-build asserts, and the
// manifest naming every emitted bundle that ends up containing it. The
// manifest is derived from esbuild's own dependency graph rather than from a
// hardcoded list, so it tracks the bundle layout instead of rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";
const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt");
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// Repo-relative, forward-slashed, so the manifest reads the same on every
// platform and can be consumed by a POSIX shell script without further work.
function repoRelative(p) {
return path.relative(__dirname, p).split(path.sep).join("/");
}
// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild
// reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification.
//
// 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() {
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
console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(__dirname, "dist", "styles.css");
ensureDir(path.join(__dirname, "dist"));
const tailwindOutput = path.join(DIST, "styles.css");
ensureDir(DIST);
// Drop any manifest from a previous build before emitting anything, so a
// build that never gets around to writing one cannot be verified against
// a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" },
);
// Every bundle goes through here, so metafile collection cannot be
// forgotten when a new entry point is added.
async function bundle(entryPoint, outfile) {
const result = await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
format: "iife",
outfile,
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
metafile: true,
define,
});
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
}
for (const distDir of [DIST_CHROME, DIST_FIREFOX]) {
ensureDir(path.join(distDir, "src", "popup"));
ensureDir(path.join(distDir, "src", "background"));
ensureDir(path.join(distDir, "src", "content"));
// bundle popup JS with esbuild (inlines ethers, libsodium, etc.)
await esbuild.build({
entryPoints: [path.join(SRC, "popup", "index.js")],
bundle: true,
format: "iife",
outfile: path.join(distDir, "src", "popup", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
await bundle(
path.join(SRC, "popup", "index.js"),
path.join(distDir, "src", "popup", "index.js"),
);
// bundle background script
await esbuild.build({
entryPoints: [path.join(SRC, "background", "index.js")],
bundle: true,
format: "iife",
outfile: path.join(distDir, "src", "background", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
await bundle(
path.join(SRC, "background", "index.js"),
path.join(distDir, "src", "background", "index.js"),
);
// bundle content script
await esbuild.build({
entryPoints: [path.join(SRC, "content", "index.js")],
bundle: true,
format: "iife",
outfile: path.join(distDir, "src", "content", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
await bundle(
path.join(SRC, "content", "index.js"),
path.join(distDir, "src", "content", "index.js"),
);
// bundle inpage script (injected into page context, separate file)
await esbuild.build({
entryPoints: [path.join(SRC, "content", "inpage.js")],
bundle: true,
format: "iife",
outfile: path.join(distDir, "src", "content", "inpage.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
await bundle(
path.join(SRC, "content", "inpage.js"),
path.join(distDir, "src", "content", "inpage.js"),
);
// copy popup HTML
fs.copyFileSync(
@@ -96,6 +204,16 @@ async function build() {
path.join(DIST_FIREFOX, "manifest.json"),
);
// Written last so a build that died partway through leaves no manifest
// at all, which script/verify-build treats as a hard failure rather than
// as "nothing to check".
const manifest = [...new Set(auditedBundles)].sort();
fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join(""));
console.log(
`Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` +
`(listed in ${repoRelative(BUNDLE_MANIFEST)})`,
);
console.log("Build complete: dist/chrome/ and dist/firefox/");
}

View File

@@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else.
## Why AutistMask Exists
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics,
tracking, and advertisements. It is no longer a simple wallet. Most alternatives
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users
without a usable option.
The most popular browser-based EVM wallet has become bloated with swap UIs,
portfolio dashboards, analytics, tracking, and advertisements. It is no longer a
simple wallet. The common alternatives only support Chromium browsers, leaving
Firefox users without a usable option.
AutistMask exists because a wallet should be a wallet. You should be able to see
your balances, send tokens, receive tokens, and connect to sites. That is all a
@@ -27,9 +27,10 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **Encrypt your recovery phrase and private keys at rest.** Your secrets are
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
authenticated encryption (via libsodium). Your password is required only when
signing a transaction. Viewing balances and addresses never requires a
password.
authenticated encryption (via libsodium). Your password is required whenever a
secret has to be decrypted: signing a transaction, signing a message or typed
data, exporting a private key, and deleting a wallet. Viewing balances and
addresses never requires a password.
- **Let you choose your own RPC endpoint.** The default is a public Ethereum
RPC, but you can point it at your own node or any provider you trust. No
@@ -56,23 +57,25 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No token auto-discovery.** AutistMask does not scan the blockchain for
tokens you might hold. You add tokens manually by contract address. This
prevents scam tokens from appearing in your wallet uninvited.
- **No third-party token list APIs.** Token balances come from the same block
explorer you configure for transaction history, and the extension ships its
own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
detection. Any token you want tracked across all your addresses, you add
yourself by contract address.
- **No phishing blocklists from third parties.** AutistMask does not phone home
to check URLs against a remote blocklist. It does maintain a local list of
known scam addresses, but this is shipped with the extension, not fetched from
a server.
- **No backend servers operated by the developer.** Nothing is sent to any
server run by AutistMask. Every network destination is listed below.
## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. It communicates with three external services:
not have a backend server. It communicates with five external destinations:
three you configure yourself, and two fixed ones used for scam detection.
### External Services
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`)
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`;
`ethereum-sepolia-rpc.publicnode.com` on Sepolia)
This is how AutistMask talks to the Ethereum network. Every wallet needs an
Ethereum node to check balances, estimate gas, broadcast transactions, and
@@ -80,27 +83,58 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node.
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups). Your addresses are
necessarily visible to the RPC provider when querying balances.
When it is contacted: on every balance refresh (every 10 seconds while the popup
is open, every 60 seconds in the background), when you type an ENS name into the
Send screen, when a send is prepared and broadcast, while a pending transaction
is polled for its receipt, and for the reverse ENS lookups used to label
addresses (cached for 12 hours).
**Blockscout API** (default: `eth.blockscout.com/api/v2`)
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
balances and recent transactions. You can change this in Settings to a
balances (including the holder counts used for spam filtering) and your recent
transactions and token transfers. You can change this in Settings to a
self-hosted Blockscout instance.
When it is contacted: on every balance refresh, and whenever a screen showing
transaction history is opened.
What gets sent: your Ethereum addresses (to look up balances and transactions).
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached
for 5 minutes. No API key is required. No user data is sent -- only a list of
token symbols (e.g. "ETH", "USDC") to get their prices.
Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
cached for 5 minutes. No API key is required. This endpoint is not
user-configurable, and it is not contacted at all while you are on a testnet,
where no USD values are shown.
What gets sent: token symbol names. No addresses, no balances, no identifying
information.
When it is contacted: while the popup is open, at most once every 5 minutes.
What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
balances, no identifying information. As with any request, CoinDesk sees your IP
address.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
warning, and shows a red warning if it finds one. This is a plain page fetch
with no API key, made by your browser. It is best-effort: if it fails, it is
silently ignored. This endpoint is not user-configurable.
When it is contacted: each time you reach the send confirmation screen.
What gets sent: the recipient address you are about to send to, and your IP
address. Your own addresses are not sent.
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them.
### What Stays Local
@@ -123,8 +157,11 @@ word recovery phrase can restore your wallet on any device without your
password. The password only protects the copy stored in this browser. If you
lose your recovery phrase, your password cannot help you recover it.
Your password is only requested when you send a transaction. Viewing balances,
receiving funds, and browsing transaction history never require your password.
Your password is requested whenever an encrypted secret must be decrypted: when
you send a transaction, when a site asks you to sign a message or typed data,
when you export an address's private key, and when you delete a wallet. Viewing
balances, receiving funds, and browsing transaction history never require your
password.
## Installation
@@ -147,34 +184,46 @@ receiving funds, and browsing transaction history never require your password.
### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet".
3. Click the die button to generate a random 12-word recovery phrase.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these
words can take your funds. If you lose them, your wallet is gone. AutistMask
cannot recover them for you.
5. Choose a password. This encrypts your recovery phrase on this device.
6. Click "Add".
5. Choose a password and confirm it. This encrypts your recovery phrase on this
device.
6. Click "Import".
### Importing an Existing Wallet
**From a recovery phrase:** Follow the same steps as creating a wallet, but
paste your existing 12 or 24 word recovery phrase instead of generating a new
one. AutistMask uses the same derivation path as MetaMask (`m/44'/60'/0'/0`), so
your addresses will match.
The Add Wallet screen has three tabs:
**From a private key:** On the Add Wallet screen, click "Have a private key
instead?" and paste your private key. This creates a single-address wallet.
**From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of
generating a new one. AutistMask uses the standard BIP-44 Ethereum derivation
path (`m/44'/60'/0'/0`), which is what other wallets use by default, so your
addresses will match and your phrase stays portable in both directions.
**From Key:** Paste a single private key. This creates a single-address wallet.
**From xprv:** Paste an extended private key. This imports the HD wallet and
scans for used addresses.
All three tabs ask for the same password fields, and the "Import" button
finishes the job.
### Adding More Addresses
HD wallets (created from a recovery phrase) can derive multiple addresses. On
the home screen, click the "+" button next to a wallet name to add the next
address. These are deterministic -- the same recovery phrase will always produce
the same sequence of addresses.
HD wallets (created from a recovery phrase or an xprv) can derive multiple
addresses. On the home screen, click the "+" button next to a wallet name to add
the next address. These are deterministic -- the same recovery phrase will
always produce the same sequence of addresses.
### Adding ERC-20 Tokens
AutistMask does not auto-discover tokens. To track a token:
Tokens you hold show up automatically only if they are in the extension's
bundled list of well-known tokens or have at least 1,000 holders; everything
else is treated as spam and hidden. To track a token explicitly (which also
shows it at zero balance), add it by contract address:
1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token".
@@ -183,12 +232,13 @@ AutistMask does not auto-discover tokens. To track a token:
4. Click "Add".
The token balance will appear on the address detail screen and on the home
screen.
screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending
1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH or any tracked ERC-20 token).
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount.
5. Click "Review" to see the confirmation screen.
@@ -200,12 +250,18 @@ The confirmation screen shows:
- **From and To addresses** with identicons and Etherscan links
- **Amount** with USD estimate
- **Your current balance** with USD estimate
- **Estimated network fee** in ETH with USD estimate
- **Network fee** — what the transfer is expected to cost, in ETH with a USD
estimate, and below it the larger amount reserved until it confirms. The
reserve is what the network requires up front and what the balance check gates
on; the refund of the difference is why the two differ
- **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan
After reviewing, click "Send" and enter your password. The transaction will be
broadcast to the network and you will see a waiting screen with a timer. Once
confirmed (or after 60 seconds), you will see either a success or error screen
with the transaction hash and an Etherscan link.
After reviewing, enter your password and click "Sign & Send". The transaction
will be broadcast to the network and you will see a waiting screen with a timer.
Once confirmed (or after 60 seconds), you will see either a success or error
screen with the transaction hash and an Etherscan link.
### Sending a Specific Token
@@ -219,10 +275,10 @@ cannot accidentally switch to a different one.
1. Click "Receive" from the home screen or an address detail view.
2. Share the QR code or copy the address using the "Copy address" button.
When receiving ERC-20 tokens, make sure the sender is sending on the Ethereum
network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks
(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be
permanently lost.
When receiving ERC-20 tokens, make sure the sender is sending on the network you
are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens
sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will
not appear and may be permanently lost.
## Connecting to Web3 Sites
@@ -236,8 +292,18 @@ pages. When a site requests access to your wallet:
time.
When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data). You must enter your
password and click "Confirm" to authorize it.
showing the transaction details (from, to, value, data, network fee, network and
nonce). Every one of those values is checked against the transaction that is
actually signed before anything is broadcast, so what you read on that screen is
what goes out or nothing does. The popup appears once the wallet has worked out
the fee and gas from the network, which takes a moment; if that fails, no popup
appears and the site is told the transaction could not be prepared. You must
enter your password and click "Confirm" to authorize it. Message and typed-data
signature requests work the same way, with a "Sign" button, and also require
your password.
If the requesting site's domain is on the phishing blocklist, all three approval
screens show a red phishing warning before you decide.
You can manage site permissions in Settings. Allowed and denied sites can be
individually removed to reset their permissions.
@@ -247,15 +313,25 @@ individually removed to reset their permissions.
AutistMask includes several defenses against common Ethereum scams, all enabled
by default:
**Known token symbol verification.** AutistMask ships a list of ~250 legitimate
ERC-20 tokens with their contract addresses. If a transaction claims to involve
a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract,
it is identified as a spoof and hidden.
**Known token symbol verification.** AutistMask ships a bundled list of
high-market-cap ERC-20 tokens with their legitimate contract addresses — a
point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
at build time and updated only when a new release ships a newer snapshot. If a
transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
but comes from an unrecognized contract, it is identified as a spoof and hidden.
In your transaction history this is the "Hide fake tokens impersonating a known
symbol" setting, which you can switch off; doing so also stops new entries being
added to the fraud contract blocklist below, since detecting a spoof is what
fills it. The send token list always applies the check. Your balances apply it
too, with one exception: a token claiming the symbol "ETH" is not filtered
there, so a fake "ETH" token can still show up in your balance list even though
it is hidden from your transaction history and from the send token list.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list. Legitimate tokens have
substantial holder counts; scam tokens deployed for address poisoning typically
have zero.
from transaction history and the send token list, and are left out of your
balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that
@@ -266,15 +342,33 @@ ETH by default) are hidden. Scammers send dust from look-alike addresses to
plant them in your transaction history. The threshold is configurable in
Settings.
All of these filters can be individually disabled in Settings if you prefer to
**Scam address list.** A list of known fraud, drainer, and phishing addresses is
shipped with the extension. Sending to one of them raises a warning on the
confirmation screen. It contains only addresses involved in fraud -- it is not a
sanctions list.
**Phishing domain warnings.** Sites asking to connect or to have something
approved are checked against a community-maintained list of known phishing
domains, and flagged with a red banner if they match. The list is built into the
extension: the check is entirely local, so nobody is told which sites you visit,
and it works offline. It is also only as current as the release you are running
— a domain added to the list upstream reaches you in the next version of the
extension, not the same day.
The first four filters can be individually disabled in Settings if you prefer to
see everything unfiltered.
## Settings
Click the gear icon on the home screen to access settings:
- **Wallets**: Add a new wallet.
- **Display**: Toggle whether tracked tokens with zero balance are shown.
- **Wallets**: Your wallets, and "+ Add wallet".
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
timestamps to UTC, and choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
@@ -282,15 +376,17 @@ Click the gear icon on the home screen to access settings:
- **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
- **About**: License, author, version, release date, and a link to the commit
this build came from.
## Frequently Asked Questions
**Is AutistMask compatible with MetaMask?**
**Can I use AutistMask alongside another wallet?**
Yes. AutistMask uses the same derivation path (`m/44'/60'/0'/0`) as MetaMask. If
you import the same recovery phrase, you will get the same addresses. You can
use both wallets side by side, though only one can be the active
`window.ethereum` provider at a time.
Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing
the same recovery phrase gives you the same addresses as any other wallet using
that path. Two wallet extensions can be installed side by side, though only one
can be the active `window.ethereum` provider at a time.
**Can I use AutistMask with a hardware wallet?**
@@ -298,8 +394,9 @@ Not yet. Hardware wallet support may be added in the future.
**Does AutistMask support networks other than Ethereum mainnet?**
Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be
added in the future.
Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other
networks are supported today. On Sepolia, USD values are not shown, because
testnet tokens have no market value.
**Where is my data stored?**
@@ -312,7 +409,7 @@ to any server operated by AutistMask.
Your data is deleted. Make sure you have your recovery phrase backed up before
uninstalling. With your recovery phrase, you can restore your wallet in
AutistMask or any other compatible wallet (MetaMask, etc.) at any time.
AutistMask or any other wallet that uses the standard derivation path.
**What happens if a transaction times out?**

162
eslint.config.js Normal file
View File

@@ -0,0 +1,162 @@
// ESLint flat config. Static analysis for make check; formatting stays with
// prettier (script/fmt-check), so nothing here touches style.
//
// The sources are CommonJS and are bundled per entrypoint by build.js, so the
// globals differ by tree and are declared per tree below. Getting that wrong in
// either direction defeats the point: too few globals buries a real no-undef in
// false positives, too many hides the next unimported identifier.
const js = require("@eslint/js");
const globals = require("globals");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
const extensionGlobals = {
chrome: "readonly",
browser: "readonly",
};
const commonjs = {
ecmaVersion: 2024,
sourceType: "commonjs",
};
module.exports = [
{
ignores: ["dist/", "node_modules/"],
},
js.configs.recommended,
{
rules: {
// The two rules this config exists for. Both are already
// error-level in the recommended set; restated so a future
// recommended-set change cannot silently downgrade them.
"no-undef": "error",
// `_`-prefixed arguments are the deliberate "present for the
// interface, unused here" marker: the popup views share one
// init(ctx) signature and three of the eight do not read ctx.
// An unused catch binding is written `catch {`, which the repo
// already does, so caught errors stay checked.
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Off tree-wide: it requires every rethrow to carry `{ cause }`,
// at 3 sites today (src/shared/balances.js 207 and 215,
// tests/e2e/firefox/run.js 131). That is a change to what the
// wallet's error paths actually throw, and it is a decision of its
// own rather than a side effect of turning a linter on — so it is
// off everywhere, including for new code, until that decision is
// made. Unlike no-useless-assignment below, this is not an
// accommodation of particular sites and must not be scoped to
// them.
"preserve-caught-error": "off",
},
},
// no-useless-assignment stays on everywhere except the two files that
// wipe decrypted key material: the `password = null` and
// `decryptedSecret = null` assignments after use are dead by construction
// — that is what a best-effort wipe is — and the rule's fix is to delete
// the wipe. 9 sites: approval.js 582, 593, 618, 648, 692, 703, 728, 764
// and confirmTx.js 459. Everything else in the tree is still checked, so
// an ordinary dead store elsewhere is still an error.
{
files: ["src/popup/views/approval.js", "src/popup/views/confirmTx.js"],
rules: {
"no-useless-assignment": "off",
},
},
// Popup and content scripts: page/window context.
{
files: ["src/popup/**/*.js", "src/content/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// MV3 background: a service worker, with no window and no document.
{
files: ["src/background/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared is bundled into both, so it may only use what both provide:
// the service worker globals are the intersection, plus the extension APIs.
{
files: ["src/shared/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared/ens.js is the documented exception to the line above: its own
// header says POPUP ONLY, it caches in localStorage, and only popup views
// require it. Linting it as a service worker would be wrong about the file.
{
files: ["src/shared/ens.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// Unit tests: jest on node.
{
files: ["tests/**/*.test.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
},
},
// The build script is a plain node program.
{
files: ["build.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The helpers the script/ entrypoints call: plain node programs too, run
// from a shell script rather than from yarn, and never bundled.
{
files: ["script/lib/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The e2e harnesses are node programs that also carry, inline, the
// callbacks they ship into the browser via page.evaluate — so both
// contexts really are present in the same file and both sets of globals
// are in scope somewhere in it.
{
files: ["tests/e2e/**/*.js"],
languageOptions: {
...commonjs,
globals: {
...globals.node,
...globals.browser,
...extensionGlobals,
},
},
},
// This config file itself.
{
files: ["eslint.config.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
];

View File

@@ -3,8 +3,11 @@
"name": "AutistMask",
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab"],
"permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"action": {
"default_popup": "src/popup/index.html"
},

View File

@@ -3,7 +3,8 @@
"name": "AutistMask",
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "<all_urls>"],
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"browser_action": {
"default_popup": "src/popup/index.html"
},

View File

@@ -7,15 +7,20 @@
"private": true,
"scripts": {
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js",
"lint": "prettier --check .",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3",
"eslint": "10.8.1",
"globals": "17.11.0",
"jest": "^30.2.0",
"playwright-core": "1.56.0",
"prettier": "^3.8.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 "$@"

16
script/check Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/check: run all checks (test, test-verify-build, 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/test-verify-build"
"$SCRIPT_DIR/check-censored"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

301
script/check-censored Executable file
View File

@@ -0,0 +1,301 @@
#!/bin/sh
# script/check-censored: assert that the competitor name RULES.md bars appears
# nowhere in this repo, and nowhere in the built extension, except where it is
# deliberate. Our own extension to scripts-to-rule-them-all, run from
# script/check and from make build.
#
# Where the name is allowed, and why each one is not negotiable away:
#
# - script/vendor-blocklist. Build-time tooling, never shipped. A pinned
# source reference that does not say what the source is cannot be verified
# by anyone, so it names it. Whole-file exemption.
# - the two provider-shim identifiers in src/content/inpage.js. Protocol
# identifiers dApps feature-detect on; renaming them does not rename them in
# their code, it only stops this wallet working on their sites.
# - the on-chain name of the MUSD ERC-20 in src/shared/tokenList.js. It is not
# what backs symbol-spoof detection — that reads symbol and address — but
# the wallet already surfaces the on-chain name of any token the user holds
# (src/shared/balances.js), and this contract's on-chain name is that
# string, so censoring the repo cannot stop the wallet displaying it.
# Dropping the entry instead would cost the user MUSD spoof detection.
#
# Everything else fails, in the working tree and under dist/. The last two are
# literals rather than whole files, so they are enforced by counting, and each
# literal is scoped to the path allowed to carry it: a file may contain the name
# only as many times as it contains the literals permitted *there*, and zero
# times anywhere else. The emitted bundles carry them too, so a plain "the name
# must not appear in dist/" could never have passed.
#
# The name itself is not written in this file. script/vendor-blocklist is the
# one place in this repo that defines it, and this reads it back out of there —
# so the repo-wide grep this check exists to enforce keeps returning exactly the
# files named above, and this file is not one of them.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere: the
# scan half runs in a re-invocation through xargs, so that the paths it works on
# arrive as arguments and cannot be reshaped by field splitting on the way in.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag. Not part of the command-line interface.
SCAN_FLAG="--scan-paths"
VENDOR_SCRIPT="$ROOT/script/vendor-blocklist"
# Set by extract_name / make_literals_file.
NAME=""
ALLOWED_LITERALS_FILE=""
FAILED=0
cleanup() {
[ -z "$ALLOWED_LITERALS_FILE" ] || rm -f "$ALLOWED_LITERALS_FILE"
}
trap cleanup EXIT INT TERM
fail() {
echo "check-censored: FAIL: $*" >&2
exit 1
}
# The name, taken from the single place that defines it. A check scanning for a
# pattern it failed to read would pass against anything, so this refuses to
# continue unless it got something that looks like the definition.
extract_name() {
[ -f "$VENDOR_SCRIPT" ] ||
fail "$VENDOR_SCRIPT is missing, and it is where the name being
checked for is defined. Nothing was scanned."
NAME="$(grep -m1 '^UPSTREAM_ORG=' "$VENDOR_SCRIPT" | cut -d'"' -f2)" ||
fail "could not read UPSTREAM_ORG from $VENDOR_SCRIPT. Nothing was
scanned."
case "$NAME" in
"" | *[!A-Za-z0-9]*)
fail "UPSTREAM_ORG in $VENDOR_SCRIPT did not yield a plain name
(got: '$NAME'). Scanning for that would prove nothing. Nothing was
scanned."
;;
esac
}
make_literals_file() {
ALLOWED_LITERALS_FILE="$(mktemp \
"${TMPDIR:-/tmp}/autistmask-censored.XXXXXX")" ||
fail "could not create a temporary file, so nothing was scanned."
}
# The literals $1 may carry, and nothing else may. Each contains the name
# exactly once, which is what makes counting them sound; each is scoped to its
# path, so a file with no business carrying the name fails even when it spells
# it the way shipped code has to. Scoping is the point: permitting these
# literals in any file is what once let this check pass its own prose.
#
# The emitted paths are listed next to the sources they come from. If the
# bundler moves one, this goes red and the new path gets added deliberately,
# rather than a wildcard over dist/ covering whatever lands there.
allowed_literals_for() {
: >"$ALLOWED_LITERALS_FILE"
case "$1" in
src/content/inpage.js | dist/*/src/content/inpage.js)
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
src/shared/tokenList.js | dist/*/src/background/index.js | \
dist/*/src/popup/index.js)
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
esac
}
# How many times does $1 contain the name (TOTAL), and how many of those are one
# of the allowed literals (ALLOWED)? Same discipline the rest of this repo's
# shell checks apply to grep: exit 0 and 1 are answers about the file, anything
# else means the file was not searched and is not an answer at all.
count_matches() {
_cm_status=0
_cm_out="$(grep -a -o -i -F -e "$NAME" -- "$1")" || _cm_status=$?
case "$_cm_status" in
0) TOTAL="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
1) TOTAL=0 ;;
*)
fail "grep exited $_cm_status reading $1, so the file was never
searched and nothing was established about it. That is a permissions or I/O
fault, not a clean file. Refusing to report success."
;;
esac
if [ "$TOTAL" -eq 0 ]; then
ALLOWED=0
return 0
fi
# No literal is permitted at this path, so every occurrence is a violation.
# Handled here rather than by grep, which is not required to say anything
# useful about an empty pattern file.
if [ ! -s "$ALLOWED_LITERALS_FILE" ]; then
ALLOWED=0
return 0
fi
_cm_status=0
_cm_out="$(grep -a -o -i -F -f "$ALLOWED_LITERALS_FILE" -- "$1")" ||
_cm_status=$?
case "$_cm_status" in
0) ALLOWED="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
1) ALLOWED=0 ;;
*)
fail "grep exited $_cm_status matching the allowed literals in $1.
Refusing to report success."
;;
esac
}
# The per-path half, run in a re-invocation of this script so it uses the same
# counting as everything else rather than a second copy of it.
scan_paths() {
for _file in "$@"; do
# dist/ arrives absolute (find) and the worktree relative (git
# ls-files). The allowlist is keyed on repo-relative paths, so both
# forms are reduced to one before anything is decided about them.
_rel="$_file"
case "$_rel" in
"$ROOT"/*) _rel="${_rel#"$ROOT"/}" ;;
esac
case "$_rel" in
script/vendor-blocklist) continue ;;
esac
[ -f "$_file" ] || continue
allowed_literals_for "$_rel"
count_matches "$_file"
[ "$TOTAL" -gt "$ALLOWED" ] || continue
FAILED=$((FAILED + 1))
echo "check-censored: $_rel: $TOTAL occurrence(s) of the name," \
"$ALLOWED of them allowed at this path" >&2
grep -a -n -i -F -e "$NAME" -- "$_file" | cut -c1-140 | head -5 >&2
done
[ "$FAILED" -eq 0 ]
}
# Hand a NUL-delimited listing to the scan half. Returns non-zero if any path
# failed, or if the scan could not be run at all.
scan_listing() {
xargs -0 "$SELF" "$SCAN_FLAG" <"$1"
}
# Every file git tracks, plus everything untracked and not ignored: the working
# tree as a reviewer would see it, and never node_modules or dist/ (both are
# ignored; dist/ is walked separately below).
check_worktree() {
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-tree.XXXXXX")" ||
fail "could not create a temporary file, so nothing was scanned."
_status=0
git ls-files -z --cached --others --exclude-standard >"$_list" ||
_status=$?
[ "$_status" -eq 0 ] || {
rm -f "$_list"
fail "git ls-files exited $_status, so the working tree was never
enumerated and nothing was established about it."
}
# Repo-relative paths. The scan half cd's to the repo root before it opens
# anything, so they reach it intact and unjoined.
WORKTREE_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
_status=0
scan_listing "$_list" || _status=$?
rm -f "$_list"
return "$_status"
}
check_dist() {
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-dist.XXXXXX")" ||
fail "could not create a temporary file, so dist/ was not scanned."
_status=0
find "$ROOT/dist" -type f -print0 >"$_list" || _status=$?
[ "$_status" -eq 0 ] || {
rm -f "$_list"
fail "find exited $_status enumerating dist/, so part of the emitted
tree was never walked and an unchecked file there went unchecked. Refusing
to report success."
}
DIST_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
_status=0
scan_listing "$_list" || _status=$?
rm -f "$_list"
return "$_status"
}
usage() {
echo "usage: script/check-censored [--require-dist]" >&2
exit 2
}
main() {
cd "$ROOT"
# Internal re-entry from scan_listing's xargs.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
extract_name
make_literals_file
scan_paths "$@"
return $?
fi
require_dist=no
case "${1-}" in
"") ;;
--require-dist) require_dist=yes ;;
*) usage ;;
esac
extract_name
make_literals_file
echo "Checking for censored names..."
tree_status=0
check_worktree || tree_status=$?
dist_status=0
dist_inspected=no
DIST_COUNT=0
if [ -d "$ROOT/dist" ]; then
dist_inspected=yes
check_dist || dist_status=$?
fi
if [ "$tree_status" -ne 0 ] || [ "$dist_status" -ne 0 ]; then
fail "the name appears outside the deliberate exceptions (reported
above). See the header of script/check-censored for what is allowed and
why."
fi
if [ "$dist_inspected" = no ]; then
if [ "$require_dist" = yes ]; then
fail "there is no dist/ to inspect and this run was asked to
require one. Run make build."
fi
cat <<EOF
################################################################################
## WARNING: dist/ WAS NOT INSPECTED BY THIS RUN AND IS NOT PROVEN CLEAN BY IT.
## There is no dist/ in this tree. The working tree is clean, but a build can
## carry text no source file does — a dependency's, or a bundler's. Every
## make build runs this check again with dist/ required, so a release artifact
## is always covered; this run simply had none to look at.
################################################################################
EOF
fi
echo "check-censored: $WORKTREE_COUNT tracked file(s) inspected," \
"$DIST_COUNT file(s) under dist/"
}
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 "$@"

View File

@@ -0,0 +1,147 @@
// The transform half of script/vendor-blocklist: upstream's config.json in,
// src/shared/phishingBlocklist.json out. Build-time repo tooling; nothing here
// is shipped to users.
//
// Usage: node script/lib/build-blocklist.js <source.json> <output.json>
//
// What it does, and why each step is here:
//
// - only the blacklist is carried over. The extension matches a hostname and
// its parent domains against that one list; upstream's whitelist, fuzzylist
// and version metadata are read by nothing here, so shipping them would add
// megabytes of dead weight to every install.
// - entries are lowercased and de-duplicated, because that is the form
// isPhishingDomain() compares against.
// - entries that cannot be a hostname are dropped and counted. Upstream
// carries the odd URL-shaped entry (a path, a scheme); hostname matching can
// never match one, and once the artifact is hashes nobody can see that it is
// in there, so it is reported at vendoring time instead.
// - entries are hashed (see src/shared/domainHash.js) and sorted, and the
// digests are concatenated into one fixed-width string. Sorted is what makes
// the runtime lookup a binary search over that string, with no set to build
// on every service-worker wake; one string rather than an array of 100k+ is
// what keeps the file, the bundle and the JSON parse small.
//
// Deterministic by construction: same input bytes, same output bytes.
"use strict";
const fs = require("fs");
const {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
} = require("../../src/shared/domainHash");
// A blocklist that has collapsed to a handful of entries is a broken fetch or a
// changed upstream shape, not a quiet day in phishing. Vendoring it would
// disarm the feature, so it fails instead and a human decides.
const MIN_ENTRIES = 10000;
function fail(message) {
process.stderr.write("build-blocklist: " + message + "\n");
process.exit(1);
}
// A hostname, as the matcher understands one: dot-separated labels of letters,
// digits, hyphens and underscores. Anything else — a path, a scheme, a space,
// an empty string, a non-ASCII label a browser would have punycoded before it
// ever reached isPhishingDomain() — cannot be produced by the hostname variants
// the extension looks up, so it could only ever sit in the artifact unused.
//
// Underscores are deliberate. They are not legal in a hostname per RFC 1123,
// but DNS carries them and browsers resolve them, and upstream lists 141 entries
// that use one — real phishing sites on shared subdomain hosts. A stricter
// pattern silently drops every one of them.
const HOSTNAME_RE =
/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)+$/;
function main(argv) {
const [source, output] = argv;
if (!source || !output) {
fail("usage: build-blocklist.js <source.json> <output.json>");
}
let config;
try {
config = JSON.parse(fs.readFileSync(source, "utf8"));
} catch (e) {
fail("could not read " + source + " as JSON: " + e.message);
}
if (!Array.isArray(config.blacklist)) {
fail(
"the source has no blacklist array, so its shape is not the one " +
"this transform understands. Refusing to write an artifact.",
);
}
const seen = new Set();
let dropped = 0;
for (const raw of config.blacklist) {
if (typeof raw !== "string") {
dropped++;
continue;
}
const domain = raw.trim().toLowerCase();
if (!HOSTNAME_RE.test(domain)) {
dropped++;
continue;
}
seen.add(domain);
}
if (seen.size < MIN_ENTRIES) {
fail(
"the source yielded " +
seen.size +
" usable entries, below the " +
MIN_ENTRIES +
" floor. That is a broken source or a changed upstream " +
"shape, and vendoring it would disarm phishing detection. " +
"Refusing to write an artifact.",
);
}
const hashes = [];
for (const domain of seen) hashes.push(hashDomain(domain));
hashes.sort();
// Truncation makes collisions possible; they are harmless (both entries are
// blocked either way) but they must not inflate the count the artifact
// claims, which the runtime cross-checks against the string length.
const unique = [];
for (const hash of hashes) {
if (unique.length === 0 || unique[unique.length - 1] !== hash) {
unique.push(hash);
}
}
const artifact = {
algorithm: HASH_ALGORITHM,
hashHexChars: HASH_HEX_CHARS,
count: unique.length,
hashes: unique.join(""),
};
// Four-space JSON with a trailing newline: what prettier emits for this
// shape, so a vendored artifact passes make fmt-check untouched.
fs.writeFileSync(output, JSON.stringify(artifact, null, 4) + "\n");
process.stdout.write(
"build-blocklist: " +
config.blacklist.length +
" source entries -> " +
seen.size +
" usable domains -> " +
unique.length +
" digests (" +
dropped +
" not hostnames, " +
(seen.size - unique.length) +
" digest collisions)\n",
);
}
main(process.argv.slice(2));

51
script/lint Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
# script/lint: run the linter (eslint, then prettier --check).
#
# Linting is containerized. ESLint results depend on the ESLint version, and
# the pinned one is the one in the image; a host's own install must not be
# able to decide whether this repo is green. From a host this therefore builds
# the Dockerfile's `lint` stage, which runs this same script inside the image.
#
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
# what stops the recursion, so `make check` inside the CI build lints in place
# instead of trying to reach a docker daemon it does not have.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
case "${AUTISTMASK_LINT_NATIVE:-}" in
1)
echo "Linting..."
yarn run lint 2>&1
return 0
;;
"") ;;
*)
# Set but not recognized: say so rather than silently taking the
# docker path, which would look like the variable had no effect.
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
exit 1
;;
esac
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2
exit 1
fi
echo "Linting in the pinned container..."
# --progress=plain: the default progress renderer collapses the lint
# output on success, and a lint run whose output cannot be seen is not
# evidence that it ran.
#
# --output=type=cacheonly: the exit status is the whole result; exporting
# an image afterwards costs about ten times the lint itself.
docker build --progress=plain --target lint \
--output=type=cacheonly . 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 "$@"

49
script/test Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/sh
# script/test: run the test suite.
#
# The timeout bounds a hung suite; it is not a performance budget. On a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
# the bound. Inside the image the same suite also pays a cold jest cache and
# shares the runner with the rest of the build, which is not what that budget
# describes, so the Dockerfile raises the bound through
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
# produces a red that means nothing, and teaches "just run it again".
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
main() {
cd "$ROOT"
echo "Running tests (timeout ${TIMEOUT}s)..."
status=0
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
[ "$status" -eq 0 ] && return 0
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
# assertion, and the verbose rerun would only spend the same wall clock
# to be killed again.
if [ "$status" -eq 124 ]; then
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
exit 1
fi
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
# is not a duration it accepts. The suite never ran, so it neither timed out
# nor failed, and the verbose rerun would only reprint the same complaint.
if [ "$status" -eq 125 ]; then
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
exit 1
fi
echo "--- Rerunning with --verbose for details ---"
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
main "$@"

83
script/test-e2e Executable file
View File

@@ -0,0 +1,83 @@
#!/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. ESLint's no-undef now catches a
# used-but-not-imported identifier in make check, but only this suite sees
# what a view actually does when it runs.
# .gitea/workflows/e2e.yml also runs it on every push, in a job separate
# from check so that cap and the local fast path both stay intact.
#
# Docker is the only prerequisite. The repo reaches the container as a
# build context and the extension is built inside it (see
# tests/e2e/Dockerfile), so nothing here depends on the node, yarn or make
# on the machine that starts the run. That is not a convenience: a bind
# mount cannot work under Gitea Actions, and the runner image's node is too
# old to install this repo's dependencies.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IIDFILE=""
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
fi
}
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
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
echo "Building the Chrome e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash.
# HOME=/tmp: the image's root home is not a reliable place for the
# browser profile.
# 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 — the JSON-RPC calls behind
# every approval the suite drives among them — goes to the real
# internet. The flag is experimental and Playwright may drop or
# rename it. It cannot break silently: the harness asks the worker
# for one request of its own at launch and aborts the whole suite
# if it does not reach the route handler (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 \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
node tests/e2e/run.js
}
main "$@"

77
script/test-e2e-firefox Executable file
View File

@@ -0,0 +1,77 @@
#!/bin/sh
# script/test-e2e-firefox: build the extension and drive the real popup in
# a real Firefox inside a pinned container. The Firefox counterpart to
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
#
# Deliberately NOT called by script/check or script/test, for the same
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
# on every push, in a job separate from check.
#
# Unlike script/test-e2e this builds its base image locally, because no
# published image carries both a pinned Firefox and a matching geckodriver.
# All three external artifacts are pinned by digest inside the Dockerfile;
# see tests/e2e/firefox/Dockerfile, which also explains why the repo and
# the extension build are baked into the image rather than mounted.
#
# Docker is the only prerequisite: nothing here depends on the node, yarn
# or make on the machine that starts the run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
fi
}
main() {
cd "$ROOT"
if ! command -v docker >/dev/null 2>&1; then
echo "test-e2e-firefox: docker is required to run the e2e suite" >&2
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
echo "Building the pinned Firefox e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
# fetch failures, so the popup flows work unchanged; see the
# network note in README.md. Weaker than the Chrome suite's
# fixture interception, and honestly so — it proves no request
# escaped, but it cannot report which ones were attempted.
# HOME=/tmp: the image's root home is not a reliable place for the
# browser profile.
#
# No --privileged. Firefox's sandbox logs
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it.
docker run --rm \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/firefox/run.js dist/firefox
}
main "$@"

444
script/test-verify-build Executable file
View File

@@ -0,0 +1,444 @@
#!/bin/sh
# script/test-verify-build: exercise every failure mode of
# script/verify-build. Our own extension to scripts-to-rule-them-all, run
# from script/check so make check covers it.
#
# Why this exists: verify-build is the build-integrity guard, and three
# separate reviews of it each found a fresh vacuous pass — the grep exit-2
# conflation, the discarded find status, the line-delimited walk. Every one
# was caught by someone building a tree by hand, because nothing in make check
# could catch it. This is that hand battery, committed and automated.
#
# Each case asserts the exit status AND a substring of the message. A guard
# that fails for the wrong reason (right status, different fault) is itself a
# defect, so matching the status alone would not be a test of anything.
#
# The fixture is a temp tree containing script/verify-build as a SYMLINK to
# the real script: verify-build takes its ROOT from dirname "$0"/.., so it
# operates on the fixture's dist/ and never reads or writes the repo's build
# output. The symlink rather than a copy is what makes a deliberate break in
# the real script fail here.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
VERIFY_BUILD="$ROOT/script/verify-build"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
NEWLINE='
'
PASSED=0
FAILED=0
SKIPPED=0
SKIPPED_NAMES=""
# The command prefix that runs the permission-dependent cases as a user who
# is actually subject to file permissions, and whether those cases can run at
# all. Both are decided by probe_permission_runner, never assumed.
UNPRIV=""
PERM_ENABLED=no
PERM_HOW=""
WORK=""
cleanup() {
[ -n "$WORK" ] || return 0
# The cases chmod 000 files and directories on purpose.
chmod -R u+rwX "$WORK" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")"
FIXTURE="$WORK/fixture"
# verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside
# our work dir keeps the run leaving no residue, and keeps it writable for the
# unprivileged user the permission cases run as.
TMPDIR="$WORK/tmp"
export TMPDIR
mkdir -p "$TMPDIR"
chmod 1777 "$TMPDIR"
chmod 755 "$WORK"
# --- fixture ---------------------------------------------------------------
# A stand-in for an emitted bundle: some text plus one marker literal, which
# is all verify-build reads out of the real thing.
write_bundle() {
printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1"
}
# A dist/ shaped like a real build: two listed bundles under different
# browsers, an unlisted subtree to make unwalkable, and unlisted files that
# carry no marker and must not be objected to.
build_fixture() {
chmod -R u+rwX "$FIXTURE" 2>/dev/null || true
rm -rf "$FIXTURE"
mkdir -p "$FIXTURE/script"
ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build"
mkdir -p "$FIXTURE/dist/chrome/src/popup" \
"$FIXTURE/dist/chrome/src/content" \
"$FIXTURE/dist/firefox/src/popup"
write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF"
write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF"
printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js"
{
echo "dist/chrome/src/popup/index.js"
echo "dist/firefox/src/popup/index.js"
} >"$FIXTURE/dist/constants-bundles.txt"
# Readable and traversable by the unprivileged user the permission cases
# run as, before those cases take that away again on purpose.
chmod -R a+rX "$FIXTURE"
}
# --- permission runner ------------------------------------------------------
# Run a command through the current unprivileged runner. Unquoted on purpose:
# UNPRIV is a command prefix that has to word-split.
run_unpriv() {
# shellcheck disable=SC2086
$UNPRIV "$@"
}
# Decide whether the permission-dependent cases can run, and prove it rather
# than assuming it.
#
# The problem: the CI image declares no USER, so CI runs as root, and root is
# not subject to file permissions — chmod 000 stops neither find nor grep. A
# permission case run as root passes vacuously, which is worse than no case at
# all because it reads as coverage.
#
# So the runner is validated with two probes before any permission case is
# counted:
#
# - a mode-644 file MUST be readable through it. If not, the runner itself
# is broken (missing helper, no such user, sandbox), and every case run
# through it would fail for the wrong reason.
# - a mode-000 file MUST NOT be readable through it. If it is, permissions
# are not in force and the cases would pass without proving anything.
#
# Unprivileged: the runner is empty and both probes are about this process,
# which is the honest answer. Root: setpriv and runuser are tried, both
# present in the pinned CI base image. Only when no candidate passes both
# probes are the cases skipped, and a skipped run says so unmistakably.
probe_permission_runner() {
_probe="$WORK/probe"
mkdir -p "$_probe"
printf 'readable\n' >"$_probe/public"
printf 'secret\n' >"$_probe/private"
chmod 755 "$_probe"
chmod 644 "$_probe/public"
chmod 000 "$_probe/private"
if [ "$(id -u)" -eq 0 ]; then
_candidates="setpriv|setpriv --reuid=65534 --regid=65534 --clear-groups --
runuser|runuser -u nobody --"
else
_candidates="direct|"
fi
_tried=""
_saved_ifs="$IFS"
IFS="$NEWLINE"
for _line in $_candidates; do
IFS="$_saved_ifs"
_label="${_line%%|*}"
_cmd="${_line#*|}"
_tried="${_tried:+$_tried, }$_label"
if [ -n "$_cmd" ]; then
_bin="${_cmd%% *}"
command -v "$_bin" >/dev/null 2>&1 || continue
fi
UNPRIV="$_cmd"
# Broken or unusable runner: the cases would fail for the wrong
# reason. Reaching the script under test is part of usable.
run_unpriv cat "$_probe/public" >/dev/null 2>&1 || continue
run_unpriv cat "$VERIFY_BUILD" >/dev/null 2>&1 || continue
# Permissions not in force through this runner: the cases would pass
# without testing anything.
if run_unpriv cat "$_probe/private" >/dev/null 2>&1; then
continue
fi
PERM_ENABLED=yes
PERM_HOW="$_label"
IFS="$_saved_ifs"
return 0
done
IFS="$_saved_ifs"
UNPRIV=""
PERM_ENABLED=no
PERM_HOW="$_tried"
}
# --- case runner ------------------------------------------------------------
# check_case <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
#
# Rebuilds the fixture, applies <setup> inside it, runs verify-build, and
# requires both the exit status and the message. <perm> marks a case that only
# means anything when file permissions are in force.
check_case() {
_name="$1"
_perm="$2"
_mode="$3"
_want_status="$4"
_want_text="$5"
_setup="$6"
if [ "$_perm" = yes ] && [ "$PERM_ENABLED" != yes ]; then
SKIPPED=$((SKIPPED + 1))
SKIPPED_NAMES="$SKIPPED_NAMES## - $_name$NEWLINE"
echo " SKIP (permissions not in force): $_name"
return 0
fi
build_fixture
if ! (cd "$FIXTURE" && "$_setup") >/dev/null 2>&1; then
FAILED=$((FAILED + 1))
echo " FAIL: $_name"
echo " the case's own setup failed, so nothing was tested."
return 0
fi
if [ "$_mode" = debug ]; then
_debug=1
else
_debug=""
fi
# Exported rather than set as a command prefix: run_unpriv is a function,
# and an assignment prefixed to a function call is not portable.
AUTISTMASK_DEBUG="$_debug"
export AUTISTMASK_DEBUG
_status=0
if [ "$_perm" = yes ]; then
_out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$?
else
_out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$?
fi
_ok=yes
_why=""
if [ "$_status" -ne "$_want_status" ]; then
_ok=no
_why="exit status $_status, wanted $_want_status"
fi
# Same discipline verify-build itself applies to grep: 0 and 1 are
# answers, anything else is not, and must not be read as "no match".
_g=0
printf '%s\n' "$_out" | grep -q -F -e "$_want_text" || _g=$?
case "$_g" in
0) ;;
1)
_ok=no
_why="${_why:+$_why; }message did not contain: $_want_text"
;;
*)
_ok=no
_why="${_why:+$_why; }grep exited $_g matching the message, so the
message was never checked"
;;
esac
if [ "$_ok" = yes ]; then
PASSED=$((PASSED + 1))
echo " ok: $_name"
return 0
fi
FAILED=$((FAILED + 1))
echo " FAIL: $_name"
echo " $_why"
echo " --- verify-build output ---"
printf '%s\n' "$_out" | sed 's/^/ /'
echo " --- end output ---"
}
# --- cases ------------------------------------------------------------------
#
# Each runs with the fixture as its working directory.
c_control() { :; }
c_trailing_space() {
cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js "
}
c_embedded_newline() {
cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js$NEWLINE"
}
c_dist_symlink() {
mv dist dist.real
ln -s dist.real dist
}
c_unwalkable_subtree() { chmod 000 dist/chrome/src/content; }
c_dangling_symlink() {
ln -s /nonexistent-target-for-test-verify-build dist/chrome/dangling.js
}
c_dir_symlink() { ln -s src dist/chrome/link-to-dir; }
c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; }
c_manifest_missing() { rm dist/constants-bundles.txt; }
c_manifest_empty() { : >dist/constants-bundles.txt; }
c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; }
c_bundle_missing() { rm dist/chrome/src/popup/index.js; }
c_bundle_empty() { : >dist/chrome/src/popup/index.js; }
c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
c_unlisted_extension() {
cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs
}
c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; }
c_both_markers() {
printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js
}
run_cases() {
check_case "control: untouched dist passes" \
no release 0 "2 bundle(s) verified $MARKER_OFF" c_control
check_case "unlisted marker-carrying file, trailing space in name" \
no release 1 "carries a debug marker but is absent from" \
c_trailing_space
check_case "unlisted marker-carrying file, newline in name" \
no release 1 "carries a debug marker but is absent from" \
c_embedded_newline
check_case "dist/ replaced by a symlink" \
no release 1 "dist is a symlink, not a directory." c_dist_symlink
check_case "unwalkable subtree under dist/" \
yes release 1 "enumerating dist/, so part of the tree" \
c_unwalkable_subtree
check_case "dangling symlink under dist/" \
no release 1 \
"reading dist/chrome/dangling.js, so the file could not be" \
c_dangling_symlink
check_case "symlink to a directory under dist/" \
no release 1 \
"reading dist/chrome/link-to-dir, so the file could not be" \
c_dir_symlink
check_case "symlink to a listed bundle under an unlisted path" \
no release 1 \
"dist/chrome/src/aliased.js carries a debug marker but is absent" \
c_alias_symlink
check_case "manifest missing" \
no release 1 "dist/constants-bundles.txt is missing." \
c_manifest_missing
check_case "manifest empty" \
no release 1 "is empty, so no emitted bundle was found to contain" \
c_manifest_empty
check_case "manifest unreadable" \
yes release 1 "is not readable, so nothing was inspected." \
c_manifest_unreadable
check_case "listed bundle missing" \
no release 1 \
"lists dist/chrome/src/popup/index.js, which does not exist." \
c_bundle_missing
check_case "listed bundle empty" \
no release 1 "which is empty. An empty bundle" c_bundle_empty
check_case "listed bundle unreadable" \
yes release 1 \
"reading dist/chrome/src/popup/index.js, so the file could not be" \
c_bundle_unreadable
check_case "unlisted extension carrying a marker" \
no release 1 \
"dist/chrome/src/popup/extra.mjs carries a debug marker but is" \
c_unlisted_extension
check_case "listed bundle carries no marker" \
no release 1 "carries no debug marker, so its DEBUG state cannot be" \
c_no_marker
check_case "listed bundle carries both markers" \
no release 1 "carries both debug markers, so DEBUG was not resolved" \
c_both_markers
check_case "wrong marker for the requested mode" \
no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \
c_control
}
# --- main --------------------------------------------------------------------
main() {
cd "$ROOT"
[ -x "$VERIFY_BUILD" ] || {
echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2
exit 1
}
echo "Testing script/verify-build failure modes..."
probe_permission_runner
if [ "$PERM_ENABLED" = yes ]; then
echo " permission cases: enabled (runner: $PERM_HOW, proved against" \
"a mode-000 file)"
fi
run_cases
if [ "$FAILED" -ne 0 ]; then
echo "test-verify-build: $FAILED case(s) FAILED," \
"$PASSED passed, $SKIPPED skipped" >&2
exit 1
fi
if [ "$SKIPPED" -ne 0 ]; then
cat <<EOF
################################################################################
## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## PROVE THEM. This process is uid $(id -u), and no runner subject to file
## permissions was available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so these cases would
## have passed without testing anything. They were skipped, not counted:
$SKIPPED_NAMES################################################################################
EOF
echo "test-verify-build: $PASSED case(s) passed," \
"$SKIPPED SKIPPED AND NOT PROVEN (see the warning above)"
return 0
fi
echo "test-verify-build: $PASSED case(s) passed"
}
main "$@"

105
script/vendor-blocklist Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/sh
# script/vendor-blocklist: refresh the vendored phishing blocklist at
# src/shared/phishingBlocklist.json from its upstream source. Our own extension
# to scripts-to-rule-them-all.
#
# This is build-time repo tooling and is not shipped. It is the one place in
# this repo that names the upstream project, because a source reference that
# does not say what the source is cannot be verified by anyone; the artifact it
# writes carries no names at all (see src/shared/domainHash.js).
# script/check-censored reads the name back out of this file rather than
# repeating it, so it stays defined exactly once.
#
# Run it deliberately, not on every build: the output is committed, and the
# extension does no runtime fetching, so the shipped list is exactly as fresh as
# the last time someone ran this and landed the result. Re-run it, land the
# diff, cut a release; that is the whole refresh path.
#
# Pinned by content hash, twice over, as REPO_POLICIES.md requires. The commit
# below is an immutable ref — the upstream default branch moves several times a
# day and cannot be pinned — and UPSTREAM_SHA256 is the sha256 of the bytes that
# commit serves. A mismatch is a hard failure: a vendoring step that accepts
# whatever it is handed is a supply-chain hole, and this one feeds a security
# warning shown to users.
#
# To move the pin: pick the new commit, run this with the new UPSTREAM_COMMIT
# and an UPSTREAM_SHA256 you have not yet updated, and it will print the hash it
# actually got. Verify that hash against the source independently before
# recording it. Never copy the "actual" line in on trust.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Upstream, pinned 2026-08-17.
UPSTREAM_ORG="MetaMask"
UPSTREAM_REPO="eth-phishing-detect"
UPSTREAM_COMMIT="6dddf74a87da3e1a0841f7ae0d1cb31aaf2c05db"
UPSTREAM_FILE="src/config.json"
UPSTREAM_SHA256="166d5b3504e8f4ed52eae37d3dd20c1a56efa0502bfb3dc957044ff8b5f1283f"
OUTPUT="src/shared/phishingBlocklist.json"
WORK=""
cleanup() {
[ -z "$WORK" ] || rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
fail() {
echo "vendor-blocklist: $*" >&2
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
fail "neither sha256sum nor shasum is available, so the fetched
source cannot be verified. Refusing to vendor unverified content."
fi
}
main() {
cd "$ROOT"
command -v curl >/dev/null 2>&1 ||
fail "curl is required to fetch the upstream list"
command -v node >/dev/null 2>&1 ||
fail "node is required to build the artifact; run script/bootstrap"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-vendor-blocklist.XXXXXX")" ||
fail "could not create a working directory"
url="https://raw.githubusercontent.com/$UPSTREAM_ORG/$UPSTREAM_REPO/$UPSTREAM_COMMIT/$UPSTREAM_FILE"
echo "Fetching $url"
curl -fsSL --proto '=https' --tlsv1.2 -o "$WORK/source.json" "$url" ||
fail "the fetch failed, so nothing was vendored"
actual="$(sha256_of "$WORK/source.json")"
if [ "$actual" != "$UPSTREAM_SHA256" ]; then
fail "sha256 mismatch on the fetched source.
expected: $UPSTREAM_SHA256
actual: $actual
The pinned commit is immutable, so the same commit serving different bytes
means the content was substituted somewhere between upstream and here.
Nothing was written. Do not update the expectation to match unless you have
verified the new bytes independently."
fi
echo "Verified sha256 $actual"
node script/lib/build-blocklist.js "$WORK/source.json" "$WORK/out.json" ||
fail "the transform failed, so nothing was written"
if [ -f "$OUTPUT" ] && cmp -s "$WORK/out.json" "$OUTPUT"; then
echo "vendor-blocklist: $OUTPUT is already up to date"
return 0
fi
cp "$WORK/out.json" "$OUTPUT"
echo "vendor-blocklist: wrote $OUTPUT (sha256 $(sha256_of "$OUTPUT"))"
}
main "$@"

295
script/verify-build Executable file
View File

@@ -0,0 +1,295 @@
#!/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)"
# Absolute path to this script, resolved before anything cd's anywhere.
# check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths"
# A literal newline, for the is_listed guard.
NEWLINE='
'
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker.
MARKER=""
# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called.
LISTING=""
fail() {
echo "verify-build: FAIL: $*" >&2
exit 1
}
cleanup() {
[ -z "$LISTING" ] || rm -f "$LISTING"
}
trap cleanup EXIT
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "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.
#
# A path containing a newline is answered without asking grep, because grep
# would read the pattern as two patterns and report a match on either. That is
# how such a path escaped this check even once the walk stopped splitting it:
# the half before the newline matched a listed line and the file was skipped.
# The manifest is line-delimited, so it cannot name such a path at all, and
# "not listed" is the only true answer.
is_listed() {
case "$1" in
*"$NEWLINE"*) return 1 ;;
esac
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
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 regular file and every symlink under dist/ is searched — that
# is the whole of what a build emits — so build.js's filter is the only place
# the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive and every name survives it
# intact, so four things are enforced here rather than assumed:
#
# - the walk is NUL-delimited and the paths reach the check as arguments, so
# no name can be reshaped on the way in. Read line by line, a name with a
# trailing space lost it to read's field splitting and the remnant then
# matched a manifest line, and a name containing a newline arrived as a
# listed path plus an empty one. Both left a marker-carrying, unlisted file
# unchecked while the script still reported success. Delivering such a name
# intact is only half of it; is_listed also has to keep it out of grep's
# pattern, for the same reason.
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline,
# so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
# - dist/ itself must be a directory and not a symlink, which main asserts
# before anything reads through it. find does not follow a symlink named on
# its own command line, so a linked dist/ collapses this walk to one entry
# and cross-checks nothing.
#
# Types other than regular files and symlinks are left out on purpose: a build
# emits none of them, and grep on a fifo would hang rather than fail.
check_unlisted_bundles() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success."
_find_status=0
find dist \( -type f -o -type l \) -print0 >"$LISTING" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] ||
fail "the unlisted-bundle scan exited $_scan_status: either a path
under dist/ failed the check reported above, or the scan could not be run
at all. Refusing to report success."
}
# The per-path half of check_unlisted_bundles. It runs in a re-invocation of
# this script, so it uses the same is_listed and has_marker as the rest of the
# file rather than a second copy of them that could drift. Paths arrive as
# arguments and are never split, joined or trimmed.
scan_dist_paths() {
for _file in "$@"; do
if is_listed "$_file"; then
continue
fi
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
}
# 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"
# Internal re-entry from check_unlisted_bundles' xargs. Not part of the
# command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
scan_dist_paths "$@"
return 0
fi
expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
# Asserted here rather than left to grep. A symlinked dist/ used to fail
# only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# single entry hit has_marker's I/O path by luck; under a grep that exits 1
# instead, the whole cross-check would have collapsed into a pass.
if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a
symlink named on its own command line, so the unlisted-bundle cross-check
would see one entry instead of the emitted tree and establish nothing about
it. Refusing to report success."
fi
[ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first."
[ -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 "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,20 @@
// AutistMask content script — bridges between inpage (window.ethereum)
// and the background service worker via extension messaging.
const {
hasBrowserNamespace,
runtimeApi,
sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
// in the manifest, so no injection is needed here. In Firefox (MV2), the
// "world" key is not supported, so we inject via a <script> tag.
if (typeof browser !== "undefined") {
if (hasBrowserNamespace()) {
const script = document.createElement("script");
script.src = browser.runtime.getURL("src/content/inpage.js");
script.src = runtimeApi().getURL("src/content/inpage.js");
script.onload = function () {
this.remove();
};
@@ -14,23 +22,27 @@ if (typeof browser !== "undefined") {
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
// Generated once at install time and stored in extension storage.
(async function sendProviderUuid() {
let uuid = null;
try {
const items = await storageGet("eip6963Uuid");
uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
await storageSet({ eip6963Uuid: uuid });
}
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
})();
// Relay requests from the page to the background script
@@ -39,27 +51,31 @@ window.addEventListener("message", (event) => {
if (event.data?.type !== "AUTISTMASK_REQUEST") return;
const { id, method, params } = event.data;
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage(
{ type: "AUTISTMASK_RPC", id, method, params, origin: location.origin },
(response) => {
sendMessage({
type: "AUTISTMASK_RPC",
id,
method,
params,
origin: location.origin,
})
.then((response) => {
if (response) {
window.postMessage(
{ type: "AUTISTMASK_RESPONSE", id, ...response },
"*",
);
}
},
);
})
.catch(() => {
// No receiver: the background context is gone. The page's promise
// stays pending, which is what it did before this was a promise
// at all; turning it into a rejection here is a change to what
// dApps see and belongs to its own issue.
});
});
// Listen for events pushed from the background (e.g. accountsChanged)
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.onMessage.addListener((msg) => {
runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") {
window.postMessage(
{

View File

@@ -2,12 +2,48 @@
// Creates window.ethereum (EIP-1193 provider) and announces via EIP-6963.
(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 = {};
let nextId = 1;
const pending = {};
// EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class
// rather than properties bolted onto an Error because this object crosses
// no boundary after construction — it is built in the page's own realm and
// handed straight to the caller's catch — so the prototype survives and
// `error.name` is a stable thing for a dApp to see.
class ProviderRpcError extends Error {
constructor(code, message, data) {
super(message);
this.name = "ProviderRpcError";
this.code = code;
if (data !== undefined) this.data = data;
}
}
// Rebuild a boundary error as the error the page catches, carrying the
// code (and data) the extension reported. Without this a dApp cannot tell
// a user's refusal (4001) from a wallet that broke, and retries or shows
// an error instead of accepting the refusal.
//
// Whatever code arrived is passed through verbatim rather than being
// matched against a list: the extension emits 4001, 4100 and 4902 today,
// and a code this file has never heard of is still the truth about what
// happened. An error reported with no code at all stays a plain Error —
// a ProviderRpcError whose `code` is undefined would advertise a
// conformance it does not have. `message` is untouched in every case.
function toPageError(error) {
const message = (error && error.message) || "Request failed";
if (error && error.code !== undefined && error.code !== null) {
return new ProviderRpcError(error.code, message, error.data);
}
return new Error(message);
}
// Listen for responses from the content script
window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return;
@@ -17,7 +53,7 @@
if (!p) return;
delete pending[id];
if (error) {
p.reject(new Error(error.message || "Request failed"));
p.reject(toPageError(error));
} else {
p.resolve(result);
}
@@ -28,6 +64,12 @@
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data;
if (eventName === "chainChanged") {
currentChainId = data;
currentNetworkVersion = String(parseInt(data, 16));
provider.chainId = currentChainId;
provider.networkVersion = currentNetworkVersion;
}
emit(eventName, data);
});
@@ -37,7 +79,7 @@
for (const cb of cbs) {
try {
cb(data);
} catch (e) {
} catch {
// ignore listener errors
}
}
@@ -57,8 +99,8 @@
const provider = {
isAutistMask: true,
isMetaMask: true, // compatibility — many dApps check this
chainId: CHAIN_ID,
networkVersion: "1",
chainId: currentChainId,
networkVersion: currentNetworkVersion,
selectedAddress: null,
async request(args) {
@@ -75,6 +117,12 @@
? result[0]
: null;
}
if (args.method === "eth_chainId" && result) {
currentChainId = result;
currentNetworkVersion = String(parseInt(result, 16));
provider.chainId = currentChainId;
provider.networkVersion = currentNetworkVersion;
}
return result;
},
@@ -131,7 +179,8 @@
return this;
},
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
// Some dApps (wagmi) probe this object to decide whether the provider
// supports the de-facto standard extras. The name is theirs, not ours.
_metamask: {
isUnlocked() {
return Promise.resolve(provider.selectedAddress !== null);
@@ -189,4 +238,19 @@
window.addEventListener("eip6963:requestProvider", 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

@@ -0,0 +1,42 @@
// Parsing for the dust threshold field in Settings.
//
// Pure: no DOM, no state, so the accepted set can be unit tested directly
// instead of through the settings view.
//
// Accepted input is plain decimal digits only, meaning a whole number of
// gwei, zero or greater. Zero is a real setting: it hides nothing.
//
// Deliberately rejected, not coerced:
// "" nothing to save
// "-1" a negative threshold has no meaning
// "1.5" fractional gwei is not a threshold the filter can use
// "100 gwei" the unit is already printed beside the field
// "0x10" hex, which Number() would silently read as 16
// "1e3" exponent notation, which Number() would silently read as 1000
//
// The last two are the reason this is a digit test and not a Number() test.
// Number() accepts both, and accepting them would put a number in the field
// that the user did not type — the same silent substitution the visible
// rejection message exists to end.
// Must render on ONE line of #flash-msg, whose reserved height
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
// wrap to two lines pushes the settings view down, which the No Layout Shift
// policy forbids. Do not lengthen this without re-running the layout test in
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
const DUST_THRESHOLD_MESSAGE =
"Please enter a whole number of gwei, zero or greater.";
// Returns the threshold in gwei, or null if the input is not one.
function parseDustThresholdGwei(raw) {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!/^[0-9]+$/.test(trimmed)) return null;
const val = Number(trimmed);
// A run of digits long enough to exceed Number's exact integer range
// would round on the way in, so it is not a threshold we can store.
if (!Number.isSafeInteger(val)) return null;
return val;
}
module.exports = { DUST_THRESHOLD_MESSAGE, parseDustThresholdGwei };

View File

@@ -56,9 +56,37 @@
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Wallet</h2>
<!-- Mode selector tabs -->
<div
class="flex border-b border-border mb-3"
id="add-wallet-tabs"
>
<button
id="tab-mnemonic"
class="px-3 py-1.5 cursor-pointer text-xs font-bold border border-border border-b-bg bg-bg -mb-px"
>
From Phrase
</button>
<button
id="tab-privkey"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From Key
</button>
<button
id="tab-xprv"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From xprv
</button>
</div>
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the
button to roll the die for a new one.
Enter your 12 or 24 word recovery phrase below, or click
the button to roll the die for a new one.
</p>
<div class="mb-1 flex justify-end">
<button
@@ -79,14 +107,56 @@
</div>
<div
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
class="text-xs mb-2 border border-border border-dashed p-2"
style="visibility: hidden"
>
Write these words down and keep them safe. Anyone with them
can take your funds; if you lose them, your wallet is gone.
Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet
is gone.
</div>
</div>
<!-- Private key form section -->
<div id="add-wallet-section-privkey" class="hidden">
<p class="mb-2">
Paste your private key below. This wallet will have a
single address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
</div>
<!-- Extended key (xprv) form section -->
<div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses. It
must be the master key for the wallet; an account-level
or child key is not supported.
</p>
<div class="mb-2">
<input
type="password"
id="import-xprv-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="xprv..."
/>
</div>
</div>
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
<p
class="text-xs text-muted mb-1"
id="add-wallet-password-hint"
>
This password encrypts your recovery phrase on this
device. You will need it to send funds.
</p>
@@ -107,64 +177,6 @@
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
id="btn-add-wallet-import-key"
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
>
Import private key
</button>
</div>
</div>
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
<div class="mb-2" id="import-key-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your private key on this device.
You will need it to send funds.
</p>
<input
type="password"
id="import-key-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-key-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-key-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
@@ -175,7 +187,7 @@
<!-- active address headline -->
<div
id="total-value"
class="text-2xl font-bold min-h-[2rem]"
class="text-2xl font-bold min-h-[2rem] text-fg"
></div>
<div
id="total-value-sub"
@@ -366,7 +378,8 @@
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 hidden"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
@@ -570,17 +583,105 @@
<div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div>
</div>
<div id="confirm-fee" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Estimated network fee
</div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div class="text-xs text-muted mb-1">Network fee</div>
<div id="confirm-fee-amount" class="text-xs"></div>
<!-- Holds its one line of space from the first paint, so
the reserve appearing when the estimate lands moves
nothing. The placeholder is never seen. -->
<div
id="confirm-fee-reserve"
class="text-xs text-muted"
style="visibility: hidden"
>
reserve pending
</div>
</div>
<div
id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-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 address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</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 id="confirm-warnings" class="mb-2 hidden"></div>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden"
class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem"
></div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount.
</div>
<div
id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
<div
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
The network fee could not be estimated, so this transaction
cannot be checked against your balance. Please go back and
try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
@@ -592,6 +693,7 @@
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-confirm-send"
@@ -706,7 +808,8 @@
</button>
<div
id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3 hidden"
class="text-xs border border-border border-dashed p-2 mt-3"
style="visibility: hidden"
></div>
</div>
@@ -734,7 +837,8 @@
</div>
<div
id="add-token-info"
class="text-xs text-muted mb-2 hidden"
class="text-xs text-muted mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="mb-2">
<label class="block mb-1 text-xs text-muted"
@@ -792,7 +896,7 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer"
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input
type="checkbox"
@@ -800,6 +904,41 @@
/>
Show tracked tokens with zero balance
</label>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
<div class="text-xs flex items-center gap-1">
<label for="settings-theme">Theme:</label>
<select
id="settings-theme"
class="border border-border p-1 bg-bg text-fg text-xs cursor-pointer"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</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">
@@ -850,6 +989,15 @@
transfers and prevent interaction with suspicious
tokens.
</p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input
type="checkbox"
id="settings-hide-spoofed-symbols"
/>
Hide fake tokens impersonating a known symbol
</label>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
@@ -898,6 +1046,64 @@
</p>
<div id="settings-denied-sites"></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>
<!-- ============ DELETE WALLET CONFIRM ============ -->
@@ -916,7 +1122,8 @@
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 hidden"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
@@ -935,6 +1142,108 @@
</button>
</div>
<!-- ============ DELETE ADDRESS CONFIRM ============ -->
<div id="view-delete-address-confirm" class="view hidden">
<button
id="btn-delete-address-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Remove Address</h2>
<p class="text-xs mb-2">
You are about to remove
<strong id="delete-address-label"></strong> from
<strong id="delete-address-wallet-name"></strong>.
</p>
<div
id="delete-address-value"
class="text-xs mb-2 break-all min-h-[1rem]"
></div>
<div
class="text-xs mb-2 border border-border border-dashed p-2"
>
This only stops this wallet from tracking the address.
Nothing is destroyed and no key is deleted. Any funds at the
address stay exactly where they are, and the address remains
yours. Any site permissions granted to this address are
forgotten.
</div>
<!-- Filled by src/popup/views/deleteAddress.js: the route
back names the wallet's own kind of key material. -->
<div
id="delete-address-recovery"
class="text-xs mb-2 border border-border border-dashed p-2"
></div>
<div
id="delete-address-balance"
class="text-xs mb-2 min-h-[1.25rem] pointer-events-none"
>
&nbsp;
</div>
<p class="text-xs text-muted mb-3">
A wallet always keeps at least one address. To remove the
last one, delete the whole wallet from Settings instead.
</p>
<div
id="delete-address-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-delete-address-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Remove Address
</button>
</div>
<!-- ============ SHOW RECOVERY PHRASE ============ -->
<div id="view-show-phrase" class="view hidden">
<button
id="btn-show-phrase-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-1">Recovery Phrase</h2>
<p class="text-xs mb-3" id="show-phrase-wallet-name"></p>
<div
class="text-xs mb-3 border border-border border-dashed p-2"
>
Anyone who has these words can take every coin and token in
this wallet, from any device, without your password. Never
type them into a website and never show them to anyone.
</div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="show-phrase-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="show-phrase-result" class="hidden">
<div
id="show-phrase-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden">
<button
@@ -991,7 +1300,8 @@
/>
<div
id="settings-addtoken-info"
class="text-xs text-muted mt-1 hidden"
class="text-xs text-muted mt-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-settings-addtoken-manual"
@@ -1013,39 +1323,84 @@
<h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction
</h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div id="tx-detail-type" class="text-xs font-bold"></div>
<!-- ── Identity ── -->
<div class="bg-well p-3 mx-1 mb-3">
<div class="mb-2">
<div class="text-xs text-muted mb-1">
Transaction hash
</div>
<div class="mb-4">
<div
id="tx-detail-hash"
class="text-xs break-all"
></div>
</div>
<div id="tx-detail-type-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div
id="tx-detail-type"
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-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="mb-2">
<div class="text-xs text-muted mb-1">From</div>
<div id="tx-detail-from" class="text-xs break-all"></div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
</div>
<div class="mb-4">
<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 id="tx-detail-calldata-section" class="mb-4 hidden">
</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-calldata-well"
class="mb-3 border border-border border-dashed p-2"
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>
<!-- ── Decoded details ── -->
<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"
@@ -1057,11 +1412,39 @@
></div>
</div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
</div>
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
<!-- ── Network details ── -->
<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>
<!-- ── Raw data ── -->
<div id="tx-detail-rawdata-section" class="hidden">
<div class="bg-well p-3 mx-1 mb-3">
<div class="mb-2">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
@@ -1069,10 +1452,20 @@
></div>
</div>
</div>
</div>
</div>
<!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden">
<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">
<span id="approve-tx-hostname" class="font-bold"></span>
wants to send a transaction.
@@ -1103,6 +1496,33 @@
<div class="text-xs text-muted mb-1">Value</div>
<div id="approve-tx-value" class="text-xs font-bold"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Network fee (max)</div>
<div
id="approve-tx-fee"
class="text-xs font-bold min-h-[1rem]"
></div>
<div
id="approve-tx-fee-detail"
class="text-xs text-muted min-h-[1rem]"
></div>
</div>
<div class="mb-3 flex justify-between">
<div>
<div class="text-xs text-muted mb-1">Network</div>
<div
id="approve-tx-network"
class="text-xs min-h-[1rem]"
></div>
</div>
<div>
<div class="text-xs text-muted mb-1">Nonce</div>
<div
id="approve-tx-nonce"
class="text-xs min-h-[1rem]"
></div>
</div>
</div>
<div id="approve-tx-data-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div id="approve-tx-data" class="text-xs break-all"></div>
@@ -1117,7 +1537,8 @@
</div>
<div
id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="flex justify-between">
<button
@@ -1138,6 +1559,14 @@
<!-- ============ SIGNATURE APPROVAL ============ -->
<div id="view-approve-sign" class="view hidden">
<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">
<span id="approve-sign-hostname" class="font-bold"></span>
wants you to sign a message.
@@ -1145,8 +1574,10 @@
<div
id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold"
class="mb-3 p-2 text-xs font-bold"
style="
visibility: hidden;
min-height: 1.25rem;
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
@@ -1183,7 +1614,8 @@
</div>
<div
id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="flex justify-between">
<button
@@ -1204,6 +1636,14 @@
<!-- ============ SITE APPROVAL ============ -->
<div id="view-approve-site" class="view hidden">
<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">
<p class="mb-2">
<span id="approve-hostname" class="font-bold"></span>

View File

@@ -1,16 +1,28 @@
// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const { DEBUG } = require("../shared/constants");
const { state, saveState, loadState } = require("../shared/state");
const { setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { $, showView } = require("./views/helpers");
const {
$,
showView,
updateDebugBanner,
setBackRenderer,
pushCurrentView,
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
// Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go
// through it; anything else falls back to the nearest restorable parent.
const { renderView, makeBackRenderer } = require("./viewRouter");
const home = require("./views/home");
const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
@@ -21,6 +33,7 @@ const receive = require("./views/receive");
const addToken = require("./views/addToken");
const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken");
const deleteAddress = require("./views/deleteAddress");
const approval = require("./views/approval");
function renderWalletList() {
@@ -53,113 +66,65 @@ async function doRefreshAndRender() {
const ctx = {
renderWalletList,
doRefreshAndRender,
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(),
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
showReceive: () => receive.show(),
showTransactionDetail: (tx) => transactionDetail.show(tx),
showSettingsView: () => settings.show(),
showSettingsAddTokenView: () => settingsAddToken.show(),
showAddWalletView: () => {
pushCurrentView();
addWallet.show();
},
showAddressDetail: () => {
pushCurrentView();
addressDetail.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();
},
showDeleteAddress: (walletIdx, addrIdx) => {
pushCurrentView();
deleteAddress.show(walletIdx, addrIdx);
},
};
// Views that can be fully re-rendered from persisted state.
// All others fall back to the nearest restorable parent.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
function needsAddress(view) {
return (
view === "address" ||
view === "address-token" ||
view === "receive" ||
view === "transaction"
);
}
function hasValidAddress() {
return (
state.selectedWallet !== null &&
state.selectedAddress !== null &&
state.wallets[state.selectedWallet] &&
state.wallets[state.selectedWallet].addresses[state.selectedAddress]
);
}
// The view modules the router renders through, keyed as it expects them.
const viewModules = {
main: { show: () => fallbackView() },
addressDetail,
addressToken,
receive,
settings,
settingsAddToken,
confirmTx,
transactionDetail,
txStatus,
};
function restoreView() {
const view = state.currentView;
if (!view || !RESTORABLE_VIEWS.has(view)) {
return fallbackView();
}
if (needsAddress(view) && !hasValidAddress()) {
return fallbackView();
}
if (view === "address-token" && !state.selectedToken) {
return fallbackView();
}
switch (view) {
case "address":
addressDetail.show();
break;
case "address-token":
addressToken.show();
break;
case "receive":
receive.show();
break;
case "settings":
settings.show();
break;
case "settings-addtoken":
settingsAddToken.show();
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
if (!renderView(state.currentView, state, viewModules)) {
fallbackView();
}
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
} else {
fallbackView();
}
break;
case "success-tx":
if (state.viewData && state.viewData.hash) {
txStatus.renderSuccess();
} else {
fallbackView();
}
break;
case "error-tx":
if (state.viewData && state.viewData.message) {
txStatus.renderError();
} else {
fallbackView();
}
break;
default:
fallbackView();
break;
}
}
function fallbackView() {
@@ -168,16 +133,14 @@ function fallbackView() {
}
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();
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
if (
@@ -197,6 +160,12 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
// Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path.
approval.show(approvalId);
showView("approve-site");
return;
@@ -208,16 +177,17 @@ async function init() {
.getElementById("view-settings")
.classList.contains("hidden")
) {
renderWalletList();
showView("main");
goBack();
return;
}
pushCurrentView();
settings.show();
});
setBackRenderer(makeBackRenderer(state, viewModules));
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);
@@ -228,6 +198,7 @@ async function init() {
addToken.init(ctx);
settings.init(ctx);
settingsAddToken.init(ctx);
deleteAddress.init(ctx);
if (!state.hasWallet) {
showView("welcome");

View File

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

View File

@@ -10,12 +10,37 @@
--color-border: #000000;
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
--color-well: #e8e8e8;
--color-danger-well: #fef2f2;
--color-section: #dddddd;
}
html.dark {
--color-bg: #000000;
--color-fg: #ffffff;
--color-muted: #aaaaaa;
--color-border: #ffffff;
--color-border-light: #444444;
--color-hover: #222222;
--color-well: #1a1a1a;
--color-danger-well: #2a0a0a;
--color-section: #2a2a2a;
}
body {
width: 396px;
overflow-x: hidden;
}
/* Copy-flash feedback: inverts colors then fades back */
.copy-flash-active {
background-color: var(--color-fg) !important;
color: var(--color-bg) !important;
transition: none;
}
.copy-flash-fade {
transition:
background-color 225ms ease-out,
color 225ms ease-out;
}

33
src/popup/theme.js Normal file
View File

@@ -0,0 +1,33 @@
// Theme management: applies light/dark class to <html> based on preference.
let mediaQuery = null;
let mediaHandler = null;
function applyTheme(theme) {
// Clean up previous system listener
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else if (theme === "light") {
document.documentElement.classList.remove("dark");
} else {
// system
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => {
if (mediaQuery.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
mediaHandler = update;
mediaQuery.addEventListener("change", update);
update();
}
}
module.exports = { applyTheme };

167
src/popup/viewRouter.js Normal file
View File

@@ -0,0 +1,167 @@
// Rendering a view the popup lands on without having navigated to it
// forward: on restore, and on Back. In both cases the view may never have
// been rendered in this page load — a reopened popup renders only the
// wallet list and the view it restores onto, so every other view is still
// the blank static template from index.html — so unhiding it is not enough.
//
// Forward navigation renders as it goes and must NOT come through here:
// rendering a second time would re-fetch and clobber whatever the view has
// in flight.
//
// The view modules are injected and nothing here touches the DOM, so the
// dispatch and its data guards can be tested directly; src/popup/index.js
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("./restorableViews");
// The views this page load has rendered.
//
// The Back path cannot otherwise tell its two cases apart. A view the popup
// never rendered is still the blank template from index.html and has to be
// rendered; a view already on the page must NOT be rendered again, because
// a second render re-fetches and overwrites whatever the user has typed
// into it and not yet saved.
//
// Registration is showView() in views/helpers.js, which is the last thing
// every render path runs — restoreView()'s, the Back path's, and every
// forward show(). That is the point of putting it there rather than in the
// individual views: a view added later registers itself with no one having
// to remember it, so this cannot decay.
//
// Module scope is page-load scope: the popup loads this module once per
// page load, and a reopened popup gets a fresh, empty set — which is
// exactly the state that makes the Back path render.
const renderedViews = new Set();
function markViewRendered(view) {
if (view) renderedViews.add(view);
}
// Begin a fresh page-load scope. The popup gets one by being loaded; the
// unit tests, which simulate several page loads against one module
// instance, ask for one.
function resetRenderedViews() {
renderedViews.clear();
}
// Home is the exception: Back re-renders it every time, which is what the
// popup did before this router existed (index.js registered
// renderWalletList() as setRenderMain(), and goBack() called it on every
// Back onto "main"). It must stay that way — the wallet list has to reflect
// what changed while the user was away from it, such as a wallet renamed or
// an address removed in Settings — and Home holds no unsaved input to lose.
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered
// without one.
const ADDRESS_VIEWS = new Set([
"address",
"address-token",
"receive",
"transaction",
]);
function needsAddress(view) {
return ADDRESS_VIEWS.has(view);
}
function hasValidAddress(state) {
return Boolean(
state.selectedWallet !== null &&
state.selectedAddress !== null &&
state.wallets[state.selectedWallet] &&
state.wallets[state.selectedWallet].addresses[state.selectedAddress],
);
}
// Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen.
//
// Returns false when the view is not one the popup renders from state, or
// when the state it would render is gone — a token no longer selected, a
// transaction no longer persisted. The caller falls back rather than
// putting an empty template on screen.
function renderView(view, state, views) {
if (!view || !RESTORABLE_VIEWS.has(view)) return false;
if (needsAddress(view) && !hasValidAddress(state)) return false;
if (view === "address-token" && !state.selectedToken) return false;
const data = state.viewData || {};
switch (view) {
case "main":
views.main.show();
return true;
case "address":
views.addressDetail.show();
return true;
case "address-token":
views.addressToken.show();
return true;
case "receive":
views.receive.show();
return true;
case "settings":
views.settings.show();
return true;
case "settings-addtoken":
views.settingsAddToken.show();
return true;
case "confirm-tx":
if (!data.pendingTx) return false;
views.confirmTx.restore();
return true;
case "transaction":
if (!data.tx) return false;
views.transactionDetail.render();
return true;
case "wait-tx":
// Resumes the receipt poll from the persisted broadcast time,
// and answers false when there is nothing resumable left.
return Boolean(views.txStatus.restoreWait());
case "success-tx":
if (!data.hash) return false;
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
views.txStatus.renderError();
return true;
default:
return false;
}
}
// The Back-path renderer, registered with setBackRenderer() in
// views/helpers.js.
//
// Returns false — leaving goBack() to unhide the view, as it always did —
// in the two cases where the view is known to be on the page already:
//
// - It is not one the popup renders from persisted state. The restored
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
// be on the stack from this page load, where forward navigation
// rendered it on the way in.
// - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above.
//
// What is left is the case the router exists for: a view on the stack that
// this page load has never rendered, whose template is still blank.
function makeBackRenderer(state, views) {
return function renderBack(view) {
if (!RESTORABLE_VIEWS.has(view)) return false;
if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) {
return false;
}
if (!renderView(view, state, views)) {
views.main.show();
}
return true;
};
}
module.exports = {
renderView,
makeBackRenderer,
markViewRendered,
resetRenderedViews,
};

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash } = require("./helpers");
const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -7,7 +7,8 @@ const { log } = require("../../shared/log");
function show() {
$("add-token-address").value = "";
$("add-token-info").classList.add("hidden");
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
const list = $("common-token-list");
list.innerHTML = getTopTokens(25)
.map(
@@ -45,7 +46,7 @@ function init(ctx) {
}
const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
@@ -58,16 +59,24 @@ function init(ctx) {
});
await saveState();
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) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
});
$("btn-add-token-back").addEventListener("click", ctx.showAddressDetail);
$("btn-add-token-back").addEventListener("click", () => {
goBack();
});
}
module.exports = { init, show };

View File

@@ -1,33 +1,121 @@
const { $, showView, showFlash } = require("./helpers");
const {
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const {
generateMnemonic,
hdWalletFromMnemonic,
isValidMnemonic,
addressFromPrivateKey,
hdWalletFromXprv,
isValidXprv,
isMasterExtendedKey,
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
function show() {
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
* Returns the wallet object if found, or undefined.
*/
function findWalletByAddress(addr) {
const lower = addr.toLowerCase();
return state.wallets.find((w) =>
w.addresses.some((a) => a.address.toLowerCase() === lower),
);
}
/**
* Check if an xpub already exists in any HD-type wallet (hd or xprv).
* Returns the wallet object if found, or undefined.
*/
function findWalletByXpub(xpub) {
return state.wallets.find((w) => w.xpub && w.xpub === xpub);
}
let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"];
const PASSWORD_HINTS = {
mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds.",
privkey:
"This password encrypts your private key on this device. You will need it to send funds.",
xprv: "This password encrypts your key on this device. You will need it to send funds.",
};
function switchMode(mode) {
currentMode = mode;
for (const m of MODES) {
$("add-wallet-section-" + m).classList.toggle("hidden", m !== mode);
const tab = $("tab-" + m);
const isActive = m === mode;
// Active: bold, solid border on top/sides, no bottom border (connects to content)
tab.classList.toggle("font-bold", isActive);
tab.classList.toggle("border-solid", isActive);
tab.classList.toggle("border-border", isActive);
tab.classList.toggle("border-b-bg", isActive);
tab.classList.toggle("bg-bg", isActive);
// Inactive: muted text, dashed border on top/sides, transparent bottom, hover invert
tab.classList.toggle("text-muted", !isActive);
tab.classList.toggle("border-dashed", !isActive);
tab.classList.toggle("border-border-light", !isActive);
tab.classList.toggle("border-b-transparent", !isActive);
tab.classList.toggle("hover:bg-fg", !isActive);
tab.classList.toggle("hover:text-bg", !isActive);
}
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
}
// Wipe the secret material this screen holds in the DOM: a generated or
// pasted recovery phrase, an imported private key or extended private key,
// and the password that would encrypt them. Registered as the view-leave
// handler as well as run on entry, so none of it survives in the hidden
// view after the user navigates away by any route, including the Settings
// gear and the import itself.
function clear() {
$("wallet-mnemonic").value = "";
$("import-private-key").value = "";
$("import-xprv-key").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
$("add-wallet-phrase-warning").style.visibility = "hidden";
}
function show() {
clear();
switchMode("mnemonic");
showView("add-wallet");
}
function init(ctx) {
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden");
});
function validatePassword() {
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return null;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return null;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return null;
}
return pw;
}
$("btn-add-wallet-confirm").addEventListener("click", async () => {
async function importMnemonic(ctx) {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showFlash(
"Enter a recovery phrase or press the die to generate one.",
);
showFlash("Enter a recovery phrase or press the die to generate one.");
return;
}
const words = mnemonic.split(/\s+/);
@@ -43,36 +131,21 @@ function init(ctx) {
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const pw = validatePassword();
if (!pw) return;
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",
"This recovery phrase is already added (" + xpubDup.name + ").",
);
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
@@ -88,6 +161,7 @@ function init(ctx) {
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
clearViewStack();
ctx.renderWalletList();
showView("main");
@@ -109,21 +183,156 @@ function init(ctx) {
}
ctx.doRefreshAndRender();
});
}
$("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
} else {
async function importPrivateKey(ctx) {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch {
showFlash("Invalid private key.");
return;
}
const pw = validatePassword();
if (!pw) return;
const duplicate = findWalletByAddress(addr);
if (duplicate) {
showFlash(
"This address already exists in wallet (" + duplicate.name + ").",
);
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
await saveState();
clearViewStack();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
}
async function importXprvKey(ctx) {
const xprv = $("import-xprv-key").value.trim();
if (!xprv) {
showFlash("Please enter your extended private key.");
return;
}
if (!isValidXprv(xprv)) {
showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
if (!isMasterExtendedKey(xprv)) {
showFlash(
"That is an account-level or child key, which cannot be imported. " +
"Please paste the master extended private key for the wallet.",
);
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch {
showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
const { xpub, firstAddress } = result;
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash("This key is already added (" + xpubDup.name + ").");
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const pw = validatePassword();
if (!pw) return;
const encrypted = await encryptWithPassword(xprv, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "xprv",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
clearViewStack();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
}
function init(ctx) {
onViewLeave("add-wallet", clear);
// Tab click handlers
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
$("tab-xprv").addEventListener("click", () => switchMode("xprv"));
// Generate mnemonic
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").style.visibility = "visible";
});
// Import / confirm
$("btn-add-wallet-confirm").addEventListener("click", async () => {
if (currentMode === "mnemonic") {
await importMnemonic(ctx);
} else if (currentMode === "privkey") {
await importPrivateKey(ctx);
} else if (currentMode === "xprv") {
await importXprvKey(ctx);
}
});
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
// Back button
$("btn-add-wallet-back").addEventListener("click", () => {
goBack();
});
}
module.exports = { init, show };

View File

@@ -7,9 +7,13 @@ const {
addressTitle,
escapeHtml,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { state, saveState } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const {
fetchRecentTransactions,
filterTransactions,
@@ -22,27 +26,24 @@ const {
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const exportPrivkey = require("./exportPrivkey");
const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the
// send and the private-key export path check it before asking for a password,
// so a wallet that cannot derive its keys says so instead of failing after the
// user has typed one in.
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
}
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() {
state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const wi = state.selectedWallet;
const ai = state.selectedAddress;
$("address-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1);
@@ -55,22 +56,18 @@ function show() {
img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%";
blockieEl.appendChild(img);
$("address-dot").innerHTML = addressDotHtml(addr.address);
$("address-full").dataset.full = addr.address;
$("address-full").textContent = addr.address;
const addrLink = etherscanAddressLink(addr.address);
$("address-etherscan-link").innerHTML =
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const usdTotal = formatUsd(getAddressValueUsd(addr));
const addrTitle = addressTitle(addr.address, state.wallets);
$("address-line").innerHTML = renderAddressHtml(addr.address, {
title: addrTitle,
ensName: addr.ensName,
});
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens");
if (addr.ensName) {
ensEl.innerHTML =
addressDotHtml(addr.address) + escapeHtml(addr.ensName);
ensEl.classList.remove("hidden");
} else {
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
}
$("address-balances").innerHTML = balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -94,18 +91,39 @@ function show() {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
pad(d.getSeconds()) +
tzStr
);
}
@@ -137,6 +155,7 @@ async function loadTransactions(address) {
state.blockscoutUrl,
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
@@ -226,7 +245,6 @@ function renderTransactions(txs) {
row.addEventListener("click", () => {
const idx = parseInt(row.dataset.tx, 10);
const tx = loadedTxs[idx];
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
tx.fromEns = ensNameMap.get(tx.from) || null;
tx.toEns = ensNameMap.get(tx.to) || null;
ctx.showTransactionDetail(tx);
@@ -236,20 +254,17 @@ function renderTransactions(txs) {
function init(_ctx) {
ctx = _ctx;
$("address-full").addEventListener("click", () => {
const addr = $("address-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-address-back").addEventListener("click", () => {
ctx.renderWalletList();
showView("main");
goBack();
});
$("btn-send").addEventListener("click", () => {
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
@@ -264,6 +279,7 @@ function init(_ctx) {
$("send-token-static").classList.add("hidden");
updateSendBalance();
resetSendValidation();
pushCurrentView();
showView("send");
});
@@ -293,87 +309,20 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
$("export-privkey-dot").innerHTML = addressDotHtml(addr.address);
$("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = "";
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
});
$("btn-export-privkey-confirm").addEventListener("click", async () => {
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").classList.remove("hidden");
// There is no private key to export for an address this wallet
// cannot derive. Without this the export screen would take a
// password and then report it as wrong.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const wallet = state.wallets[state.selectedWallet];
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
secret,
);
const privateKey = signer.privateKey;
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").classList.add("hidden");
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
// No pushCurrentView() here: exportPrivkey.show() can return
// without navigating, so it does its own push.
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
}
});
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
show();
});
exportPrivkey.init();
}
module.exports = { init, show };

View File

@@ -5,19 +5,20 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
balanceLine,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
fetchRecentTransactions,
filterTransactions,
@@ -30,35 +31,46 @@ const {
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { walletDefect } = require("../../shared/walletDefects");
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) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
pad(d.getSeconds()) +
tzStr
);
}
@@ -126,15 +138,16 @@ function show() {
blockieEl.appendChild(img);
// Address line
$("address-token-dot").innerHTML = addressDotHtml(addr.address);
$("address-token-full").dataset.full = addr.address;
$("address-token-full").textContent = addr.address;
const addrLink = etherscanAddressLink(addr.address);
$("address-token-etherscan-link").innerHTML =
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const addrTitle = addressTitle(addr.address, state.wallets);
$("address-token-line").innerHTML = renderAddressHtml(addr.address, {
title: addrTitle,
ensName: addr.ensName,
});
$("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
// USD total for this token only
const usdVal = price ? amount * price : 0;
const usdVal = price ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
@@ -171,15 +184,9 @@ function show() {
? knownToken.decimals
: 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;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml +=
`<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>`;
infoHtml += `<div class="mb-2">${renderAddressHtml(tokenId)}</div>`;
if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol)
@@ -191,6 +198,7 @@ function show() {
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>`;
contractInfo.innerHTML = infoHtml;
attachCopyHandlers(contractInfo);
contractInfo.classList.remove("hidden");
} else {
contractInfo.innerHTML = "";
@@ -211,6 +219,7 @@ async function loadTransactions(address, tokenId) {
state.blockscoutUrl,
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
@@ -312,27 +321,25 @@ function renderTransactions(txs) {
function init(_ctx) {
ctx = _ctx;
$("address-token-full").addEventListener("click", () => {
const addr = $("address-token-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("address-token-contract-info").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]");
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
}
});
$("btn-address-token-back").addEventListener("click", () => {
ctx.showAddressDetail();
goBack();
});
$("btn-address-token-send").addEventListener("click", () => {
const defect = walletDefect(state.wallets[state.selectedWallet]);
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
@@ -356,27 +363,14 @@ function init(_ctx) {
$("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`;
if (tokenId !== "ETH") {
const dot = addressDotHtml(tokenId);
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>`;
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
}
$("send-token-static").innerHTML = staticHtml;
$("send-token-static").classList.remove("hidden");
// Attach copy handler for the contract address
const copyEl = $("send-token-static").querySelector("[data-copy]");
if (copyEl) {
copyEl.addEventListener("click", () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
});
}
attachCopyHandlers($("send-token-static"));
updateSendBalance();
resetSendValidation();
pushCurrentView();
showView("send");
});

View File

@@ -1,44 +1,39 @@
const {
$,
addressDotHtml,
addressTitle,
escapeHtml,
showView,
showError,
hideError,
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
formatUnits,
getBytes,
Interface,
toUtf8String,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const 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 { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
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);
let html = "";
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;
return renderAddressHtml(address, { title });
}
function formatTxValue(val) {
@@ -53,10 +48,6 @@ function tokenLabel(address) {
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
// Try to decode calldata using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
@@ -155,14 +146,73 @@ function decodeCalldata(data, toAddress) {
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");
}
}
// The fields of the approved transaction the value and recipient lines do not
// already carry: network, gas limit, fee per gas, the most the fee can come to,
// and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
: "Unknown network (chain id " + BigInt(approvedTx.chainId) + ")";
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
let detail =
gasLimit.toString() +
" gas at up to " +
formatUnits(feePerGas, 9) +
" gwei";
if (approvedTx.maxPriorityFeePerGas) {
detail +=
", " +
formatUnits(approvedTx.maxPriorityFeePerGas, 9) +
" gwei priority";
}
$("approve-tx-fee-detail").textContent = detail;
$("approve-tx-nonce").textContent = BigInt(approvedTx.nonce).toString();
}
function showTxApproval(details) {
const toAddr = details.txParams.to;
showPhishingWarning(
"approve-tx-phishing-warning",
details.isPhishingDomain,
);
// The transaction the background populated. It is displayed as it stands,
// signed as it stands, and verified against as it stands — the popup fills
// nothing in, so there is no number on this screen that the background
// cannot compare with the artifact it gets back.
pendingTxParams = details.approvedTx;
const approvedTx = details.approvedTx;
const toAddr = approvedTx.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0");
const ethValue = formatEther(approvedTx.value || "0");
// Build txInfo for status screens
pendingTxDetails = {
from: state.activeAddress,
from: details.approvedFrom,
to: toAddr || "",
amount: formatTxValue(ethValue),
token: "ETH",
@@ -170,7 +220,7 @@ function showTxApproval(details) {
};
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
@@ -208,7 +258,7 @@ function showTxApproval(details) {
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null;
@@ -218,17 +268,21 @@ function showTxApproval(details) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
}
toHtml += approvalAddressHtml(toAddr);
if (symbol) {
const link = etherscanTokenLink(toAddr);
toHtml = toHtml.replace("</div>", "") + ""; // approvalAddressHtml already has etherscan link
}
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
}
const ethValueFormatted = formatTxValue(
formatEther(approvedTx.value || "0"),
);
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
$("approve-tx-value").textContent =
formatTxValue(formatEther(details.txParams.value || "0")) + " ETH";
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice);
// Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded");
@@ -243,12 +297,9 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const tLink = etherscanTokenLink(d.address);
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 {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
@@ -261,17 +312,23 @@ function showTxApproval(details) {
}
// Always show raw data when present
if (details.txParams.data && details.txParams.data !== "0x") {
$("approve-tx-data").textContent = details.txParams.data;
if (approvedTx.data && approvedTx.data !== "0x") {
$("approve-tx-data").textContent = approvedTx.data;
$("approve-tx-data-section").classList.remove("hidden");
} else {
$("approve-tx-data-section").classList.add("hidden");
}
$("approve-tx-password").value = "";
$("approve-tx-error").classList.add("hidden");
hideError("approve-tx-error");
showView("approve-tx");
attachCopyHandlers("view-approve-tx");
gateOnWalletDefect(
"approve-tx-error",
"btn-approve-tx",
details.approvedFrom,
);
}
function decodeHexMessage(hex) {
@@ -323,10 +380,19 @@ function formatTypedDataHtml(jsonStr) {
}
function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.isPhishingDomain,
);
const sp = details.signParams;
pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
$("approve-sign-from").innerHTML = approvalAddressHtml(
details.approvedFrom,
);
const isTyped =
sp.method === "eth_signTypedData_v4" ||
@@ -351,10 +417,10 @@ function showSignApproval(details) {
if (warningEl) {
if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning;
warningEl.classList.remove("hidden");
warningEl.style.visibility = "visible";
} else {
warningEl.textContent = "";
warningEl.classList.add("hidden");
warningEl.style.visibility = "hidden";
}
}
@@ -364,12 +430,28 @@ function showSignApproval(details) {
$("btn-approve-sign").classList.remove("text-muted");
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
gateOnWalletDefect(
"approve-sign-error",
"btn-approve-sign",
details.approvedFrom,
);
}
function show(id) {
// Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id;
runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
approvalPort = runtimeApi().connect({ name: "approval:" + id });
let details = null;
try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
} catch {
details = null;
}
if (!details) {
window.close();
return;
@@ -382,77 +464,243 @@ function show(id) {
showSignApproval(details);
return;
}
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
});
}
let approvalId = null;
// The port this approval was opened on. Closing this window disconnects it,
// and the background treats that disconnect as "closed without deciding" for a
// site connection — so the decision goes out on this same port and not as a
// one-off message. One channel is ordered: a message posted on it is delivered
// before its own disconnect, however immediately the close follows. Two
// channels were not, and the close won, reporting a user who approved as
// having refused.
let approvalPort = null;
let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval
// time. All are repopulated by show() when the popup is closed and reopened.
let pendingTxParams = null;
let pendingSignParams = null;
// The address the approval was raised for. Signing uses this rather than the
// active address, so that an address switch since the approval fails here
// instead of producing a signature from an account the screen never named.
let pendingSignFrom = 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);
}
// Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose keys
// cannot be derived. Without this the screen would take a password and fail
// after deriving it. Reject stays available; the wallet is not touched.
// Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId, address) {
const owner = findWalletFor(address);
const defect = owner ? walletDefect(owner.wallet) : null;
if (!defect) return false;
showError(errorId, defect.shortMessage);
$(buttonId).disabled = true;
$(buttonId).classList.add("text-muted");
return true;
}
// Locate the wallet and the address index owning an address. Returns null when
// no wallet holds it. Approvals look up the address they were raised for, not
// whichever address is active now: the approval named one account, and signing
// with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) {
return { wallet, addrIndex: i };
}
}
}
return null;
}
// Drop the password from the DOM when either approval screen is left. The
// approval window navigates on after a signature — approve-tx goes to the
// wait screen — and the password must not sit in the hidden view for the
// life of that window.
function clearTxPassword() {
$("approve-tx-password").value = "";
hideError("approve-tx-error");
}
function clearSignPassword() {
$("approve-sign-password").value = "";
hideError("approve-sign-error");
}
// Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
});
} catch {
// Nothing to report it to; the window closes either way.
}
}
window.close();
}
function init(_ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
function init(ctx) {
$("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked;
await saveState();
});
$("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
remember,
});
window.close();
decideSite(true);
});
$("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
remember,
});
window.close();
decideSite(false);
});
$("btn-approve-tx").addEventListener("click", () => {
const password = $("approve-tx-password").value;
$("btn-approve-tx").addEventListener("click", async () => {
let password = $("approve-tx-password").value;
if (!password) {
showError("approve-tx-error", "Please enter your password.");
return;
}
hideError("approve-tx-error");
$("btn-approve-tx").disabled = true;
$("btn-approve-tx").classList.add("text-muted");
setTxButtonBusy(true);
runtime.sendMessage(
{
const active = findWalletFor(pendingTxParams.from);
if (!active) {
password = null;
showError(
"approve-tx-error",
"No wallet was found for the address this transaction was approved for.",
);
setTxButtonBusy(false);
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-tx-error", defect.shortMessage);
setTxButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signed transaction does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-tx-error",
"That password is incorrect. Please try again.",
);
setTxButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password,
},
(response) => {
};
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
decryptedSecret,
);
// Sign the approved transaction exactly as it was displayed. The
// background populated it before this screen was drawn and checks
// the artifact against it field for field, so there is nothing to
// fill in here and no provider to fill it in from. The copy is
// because ethers may strip `from` off what it is handed, and the
// approval has to survive a retry intact; keeping `from` on it
// makes ethers refuse a key that is not the approved address.
payload.rawSignedTx = await signer.signTransaction({
...pendingTxParams,
});
} 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;
}
// A send that never reaches the background is reported to the user
// the same way a background that refused it is: describeSigningFailure
// turns a null response into the generic message below.
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
} else {
const msg =
(response && response.error) || "Transaction failed.";
txStatus.showError(pendingTxDetails, null, msg);
return;
}
},
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else {
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
$("btn-reject-tx").addEventListener("click", () => {
runtime.sendMessage({
notify({
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: false,
@@ -460,40 +708,117 @@ function init(ctx) {
window.close();
});
$("btn-approve-sign").addEventListener("click", () => {
const password = $("approve-sign-password").value;
$("btn-approve-sign").addEventListener("click", async () => {
let password = $("approve-sign-password").value;
if (!password) {
showError("approve-sign-error", "Please enter your password.");
return;
}
hideError("approve-sign-error");
$("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted");
setSignButtonBusy(true);
runtime.sendMessage(
{
const active = findWalletFor(pendingSignFrom);
if (!active) {
password = null;
showError(
"approve-sign-error",
"No wallet was found for the address this request was approved for.",
);
setSignButtonBusy(false);
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-sign-error", defect.shortMessage);
setSignButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-sign-error",
"That password is incorrect. Please try again.",
);
setSignButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password,
},
(response) => {
};
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;
}
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.signature) {
window.close();
} else {
const msg =
(response && response.error) || "Signing failed.";
showError("approve-sign-error", msg);
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
return;
}
},
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
$("btn-reject-sign").addEventListener("click", () => {
runtime.sendMessage({
notify({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,

View File

@@ -2,43 +2,47 @@
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
const {
$,
showError,
hideError,
showView,
showFlash,
addressTitle,
addressDotHtml,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
onViewLeave,
} = require("./helpers");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { hasZeroTransactionHistory } = require("../../shared/transactions");
const { ERC20_ABI } = require("../../shared/constants");
const {
getLocalWarnings,
getFullWarnings,
} = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
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;
// Network fee for the transaction currently on screen. Reset by show() and
// filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING;
let feeWei = null;
function restore() {
const d = state.viewData;
@@ -47,14 +51,6 @@ function restore() {
}
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`;
}
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
@@ -62,22 +58,10 @@ function blockieHtml(address) {
function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
const dot = addressDotHtml(address);
const link = etherscanAddressLink(address);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
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;
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
);
}
function valueWithUsd(text, usdAmount) {
@@ -89,6 +73,8 @@ function valueWithUsd(text, usdAmount) {
function show(txInfo) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
@@ -104,22 +90,12 @@ function show(txInfo) {
// Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section");
if (isErc20) {
const dot = addressDotHtml(txInfo.token);
const link = etherscanTokenLink(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>`;
$("confirm-token-contract").innerHTML = renderAddressHtml(
txInfo.token,
{},
);
tokenSection.classList.remove("hidden");
// Attach click-to-copy on the contract address
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
};
}
attachCopyHandlers(tokenSection);
} else {
tokenSection.classList.add("hidden");
}
@@ -166,36 +142,85 @@ function show(txInfo) {
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
}
// Check for warnings
const warnings = [];
if (isScamAddress(txInfo.to)) {
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.");
}
// Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, {
fromAddress: txInfo.from,
});
const warningsEl = $("confirm-warnings");
if (warnings.length > 0) {
warningsEl.innerHTML = warnings
if (localWarnings.length > 0) {
warningsEl.innerHTML = localWarnings
.map(
(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("");
warningsEl.classList.remove("hidden");
warningsEl.style.visibility = "visible";
} else {
warningsEl.classList.add("hidden");
warningsEl.innerHTML = "";
warningsEl.style.visibility = "hidden";
}
// Check for errors
const errors = [];
if (isErc20) {
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
if (parseFloat(txInfo.amount) > tokenBal) {
errors.push(
// The two fee messages are mutually exclusive per transaction type, and
// the type is known here, before the first paint. Drop the one that can
// never apply and reserve the space of the one that can, so the async
// estimate landing later never moves anything.
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
renderValidation(txInfo);
// Reset password field and error
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
$("confirm-fee-amount").textContent = "Estimating...";
setVisible("confirm-fee-reserve", false);
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");
attachCopyHandlers("view-confirm-tx");
// Reset async warnings to hidden (space always reserved, no layout shift)
$("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);
checkRecipientHistory(txInfo);
}
// Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
feeWei,
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
"Insufficient " +
symbol +
" balance. You have " +
@@ -209,8 +234,8 @@ function show(txInfo) {
".",
);
}
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
errors.push(
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
txInfo.balance +
" ETH but are trying to send " +
@@ -220,39 +245,53 @@ function show(txInfo) {
}
const errorsEl = $("confirm-errors");
const sendBtn = $("btn-confirm-send");
if (errors.length > 0) {
errorsEl.innerHTML = errors
.map((e) => `<div class="text-xs">${e}</div>`)
if (messages.length > 0) {
errorsEl.innerHTML = messages
.map((m) => `<div class="text-xs">${escapeHtml(m)}</div>`)
.join("");
errorsEl.classList.remove("hidden");
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
errorsEl.style.visibility = "visible";
} else {
errorsEl.classList.add("hidden");
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
}
// Reset password field and error
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible(
"confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
codes.includes(CODES.FEE_UNAVAILABLE),
);
// Gas estimate — show placeholder then fetch async
$("confirm-fee").classList.remove("hidden");
$("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");
// While the estimate is in flight there is no error to show — the fee
// line already reads "Estimating..." — but sending stays blocked so a
// transaction the fee would break cannot be signed in the meantime.
const sendBtn = $("btn-confirm-send");
sendBtn.disabled = !canSend;
sendBtn.classList.toggle("text-muted", !canSend);
}
estimateGas(txInfo);
checkRecipientHistory(txInfo);
function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// A fee in wei as an ETH string, truncated to 6 decimal places.
function formatFeeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice;
let gasLimit;
if (txInfo.token === "ETH") {
@@ -270,42 +309,91 @@ async function estimateGas(txInfo) {
});
}
const gasCostWei = gasLimit * gasPrice;
const gasCostEth = formatEther(gasCostWei);
// Format to 6 significant decimal places
const parts = gasCostEth.split(".");
const dec =
parts.length > 1
? parts[1].slice(0, 6).replace(/0+$/, "") || "0"
: "0";
const feeStr = parts[0] + "." + dec + " ETH";
// What the node will require to be reserved, which is what the gate
// must be: the send pins no fee fields, so it is broadcast as a
// type-2 transaction priced at maxFeePerGas.
const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) {
throw new Error("no usable gas price from the provider");
}
// What the transaction is expected to cost, which is a different and
// usually much smaller number. Both are shown: quoting only the
// reserve overstates the typical cost by roughly double on mainnet,
// and quoting only the estimate contradicts the balance check.
const estimateWei = feeEstimateWei(gasLimit, feeData);
// The user may have left this transaction while the estimate was in
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
const ethPrice = getPrice("ETH");
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
const usd = (wei) =>
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = valueWithUsd(
"~" + formatFeeEth(estimateWei),
usd(estimateWei),
);
$("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = valueWithUsd(
formatFeeEth(gasCostWei),
usd(gasCostWei),
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
renderValidation(txInfo);
} catch (e) {
log.errorf("gas estimation failed:", e.message);
if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false);
feeStatus = FEE_UNAVAILABLE;
feeWei = null;
renderValidation(txInfo);
}
}
async function checkRecipientHistory(txInfo) {
const isNew = await hasZeroTransactionHistory(
txInfo.to,
state.blockscoutUrl,
);
if (!isNew) return;
const warningsEl = $("confirm-warnings");
const warningHtml =
`<div class="border border-red-500 border-dashed p-2 mb-1 text-xs font-bold text-red-500">` +
`WARNING: This address has ZERO transaction history. ` +
`It has never sent or received any funds. ` +
`Double-check the address before sending.</div>`;
warningsEl.innerHTML = warningHtml + warningsEl.innerHTML;
warningsEl.classList.remove("hidden");
try {
const provider = getProvider(state.rpcUrl);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
for (const w of asyncWarnings) {
if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible";
}
if (w.type === "new-address") {
$("confirm-recipient-warning").style.visibility = "visible";
}
if (w.type === "etherscan-phishing") {
$("confirm-etherscan-warning").style.visibility = "visible";
}
}
} catch (e) {
log.errorf("recipient history check failed:", e.message);
}
}
function init(ctx) {
// Drop the password from the DOM. Registered as the view-leave handler so
// it does not sit in the hidden view once the screen navigates on — to the
// wait screen after a send, or anywhere else the user goes.
function clearPassword() {
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
}
function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
const password = $("confirm-tx-password").value;
if (!password) {
@@ -325,8 +413,11 @@ function init(ctx) {
wallet.encryptedSecret,
password,
);
} catch (e) {
showError("confirm-tx-password-error", "Wrong password.");
} catch {
showError(
"confirm-tx-password-error",
"That password is incorrect. Please try again.",
);
return;
}
@@ -375,7 +466,7 @@ function init(ctx) {
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
goBack();
});
}

View File

@@ -0,0 +1,176 @@
// Confirmation screen for removing one address from a wallet that derives
// its addresses from an extended key.
//
// No password is asked for, unlike delete-wallet. A password gates the
// disclosure or destruction of a secret, and this does neither: the address
// is derived from key material the wallet still holds, so removing it only
// stops the wallet tracking it. An explicit confirmation screen is the
// proportionate treatment.
const {
$,
showView,
showFlash,
goBack,
renderAddressHtml,
attachCopyHandlers,
addressHoldsFunds,
balanceLinesForAddress,
} = require("./helpers");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state");
const {
canRemoveAddress,
removeAddressFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
// The wallet and address indices this screen is confirming, or null when it
// is not confirming anything.
let target = null;
let ctx = null;
function setFlash(msg) {
const el = $("delete-address-flash");
el.textContent = msg;
el.style.visibility = msg ? "visible" : "hidden";
}
// What it actually takes to get the address back, which is not what the
// screen used to claim.
//
// Neither obvious route works: "+" derives the next unused index, because
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and
// re-importing this wallet's key material is refused as a duplicate by
// findWalletByXpub() for as long as the wallet is here. What remains is to
// delete the whole wallet in Settings — which asks for the password and
// destroys the stored secret — and import again, after which
// scanForAddresses() rediscovers the address only if it has on-chain
// activity. An address that was never used is not found by that scan, and
// the copy must not imply otherwise.
//
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
// this screen is offered on xprv wallets too.
function recoveryPathText(wallet) {
const secret = walletHasRecoveryPhrase(wallet)
? "recovery phrase"
: "extended private key";
return (
"Getting the address back into this list is not easy, so be sure. " +
"Adding an address derives the next unused one, not this one, and " +
"importing this " +
secret +
" again is refused while this wallet is still here. The way back is " +
"to delete the whole wallet in Settings, which asks for your " +
"password and destroys the stored " +
secret +
", and then import that " +
secret +
" again. The scan that follows only finds addresses that have " +
"on-chain activity, so an address that has never been used is not " +
"found by it."
);
}
// The balance warning, or a blank line when the address holds nothing.
//
// A balance is a reason to be careful, not a reason to refuse: the funds are
// at the address, not in this list, and stay there either way.
//
// "Holds" means ETH or any ERC-20 the wallet knows about — an address with no
// ETH and a five-figure stablecoin position must not get the blank line on
// the one screen whose job is to warn. The sentence names no figure of its
// own: the rendered lines round to four decimals, so a sentence built from a
// rounded number would report "0.0000 ETH" for an address holding real money.
// The lines below it carry the amounts, in the same format as Home and
// AddressDetail, followed by the USD total when there is one to give — no
// total line at all on testnet or before the first price fetch, and no figure
// when every holding here is one with no price, since "$0.00" directly under
// "This address holds a balance." is a contradiction.
function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;";
const line = formatAddressTotal(getAddressValue(addr));
const total = line
? `<div class="text-xs text-muted mt-1">${line}</div>`
: "";
return (
`<p class="mb-1">This address holds a balance. Removing it does not ` +
`move or spend anything; the balance stays at the address.</p>` +
balanceLinesForAddress(addr, state.trackedTokens, false) +
total
);
}
function show(walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const addr = wallet && wallet.addresses[addrIdx];
if (!addr) return;
target = { walletIdx, addrIdx };
$("delete-address-label").textContent = "Address " + (addrIdx + 1);
$("delete-address-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
const value = $("delete-address-value");
value.innerHTML = renderAddressHtml(addr.address, {
ensName: addr.ensName,
});
attachCopyHandlers(value);
$("delete-address-recovery").textContent = recoveryPathText(wallet);
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
setFlash("");
showView("delete-address-confirm");
}
function init(_ctx) {
ctx = _ctx;
$("btn-delete-address-back").addEventListener("click", () => {
target = null;
goBack();
});
$("btn-delete-address-confirm").addEventListener("click", async () => {
if (target === null) {
setFlash("No address is selected for removal.");
return;
}
const { walletIdx, addrIdx } = target;
if (!canRemoveAddress(state.wallets[walletIdx])) {
setFlash(
"This address cannot be removed, because a wallet always " +
"keeps at least one address.",
);
return;
}
const { removed, activeAddressChanged } = removeAddressFromState(
state,
walletIdx,
addrIdx,
);
if (!removed) {
setFlash("This address could not be removed.");
return;
}
target = null;
// Save before broadcasting: the background reads the active address
// back out of storage to build accountsChanged.
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
ctx.renderWalletList();
goBack();
showFlash("Address removed.");
});
}
// recoveryPathText and balanceWarningHtml are exported so the two pieces of
// copy that carry the screen's substance can be tested without a DOM; show()
// is a one-line assignment for each.
module.exports = { init, show, recoveryPathText, balanceWarningHtml };

View File

@@ -1,27 +1,50 @@
const { $, showView, showFlash } = require("./helpers");
const {
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
let deleteWalletIndex = null;
let ctx = null;
// Drop the password from the DOM and the wallet selection from the
// closure. Registered as the view-leave handler as well as run on entry,
// so the typed password does not sit in the hidden view after the user
// navigates away by any route, including the Settings gear.
function clear() {
deleteWalletIndex = null;
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
}
function show(walletIdx) {
clear();
deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").classList.add("hidden");
showView("delete-wallet-confirm");
}
function init(_ctx) {
ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear);
// No wipe here: goBack() routes through showView(), which runs the
// leave hook.
$("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null;
ctx.showSettingsView();
goBack();
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
@@ -29,14 +52,14 @@ function init(_ctx) {
if (!pw) {
$("delete-wallet-flash").textContent =
"Please enter your password.";
$("delete-wallet-flash").classList.remove("hidden");
$("delete-wallet-flash").style.visibility = "visible";
return;
}
if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-flash").classList.remove("hidden");
$("delete-wallet-flash").style.visibility = "visible";
return;
}
@@ -50,44 +73,41 @@ function init(_ctx) {
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden");
} catch {
$("delete-wallet-flash").textContent =
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false;
btn.classList.remove("text-muted");
return;
}
// Collect addresses to clean up from allowedSites/deniedSites
const addresses = (wallet.addresses || []).map((a) => a.address);
// Remove wallet
state.wallets.splice(walletIdx, 1);
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
// Remove the wallet and repair selection, permissions and hasWallet
const { activeAddressChanged } = removeWalletFromState(
state,
walletIdx,
);
deleteWalletIndex = null;
if (state.wallets.length === 0) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
if (!state.hasWallet) {
clearViewStack();
await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
} else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList();
ctx.showSettingsView();
const settings = require("./settings");
settings.show();
showFlash("Wallet deleted.");
}
});

View File

@@ -0,0 +1,174 @@
// Private key export for a single address.
//
// The key controls the address outright — anyone holding it can move every
// token in it, from any device, forever — so this screen is handled under
// the same rules as the recovery phrase screen (./showPhrase.js):
//
// 1. Nothing is decrypted, no key is derived, and nothing is written into
// the DOM until decryptWithPassword has accepted the password.
// 2. Leaving the screen by any path wipes it, via the onViewLeave hook,
// and a decrypt still in flight when that happens is discarded
// instead of written (revealGeneration).
// 3. The key never reaches the logger. This module deliberately does not
// import src/shared/log.js.
//
// The key is also never assigned to `state`, so it cannot be persisted to
// extension storage, and "export-privkey" is excluded from RESTORABLE_VIEWS
// so the popup can never reopen onto it.
const {
$,
showView,
showFlash,
flashCopyFeedback,
goBack,
onViewLeave,
pushCurrentView,
renderAddressHtml,
attachCopyHandlers,
} = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const makeBlockie = require("ethereum-blockies-base64");
const VIEW = "export-privkey";
let walletIndex = null;
let addressIndex = null;
// Bumped by every clear(), which is what leaving the screen runs. reveal()
// captures it before awaiting the decrypt and refuses to touch the DOM if
// it has moved: a decrypt still in flight when the screen is left would
// otherwise write the key *after* the wipe, with nothing scheduled to wipe
// it again, leaving it in the hidden view for the life of the popup.
let revealGeneration = 0;
// True only if the reveal that captured `generation` is still the live one:
// the screen has not been left, cleared, or re-entered for another address
// since it started.
function isCurrentReveal(generation) {
return (
generation === revealGeneration &&
walletIndex !== null &&
addressIndex !== null &&
state.currentView === VIEW
);
}
function fail(message) {
$("export-privkey-flash").textContent = message;
$("export-privkey-flash").style.visibility = "visible";
}
// Wipe every trace of the key and drop the address selection. Safe to call
// when nothing was ever revealed, and safe to call twice.
function clear() {
walletIndex = null;
addressIndex = null;
revealGeneration += 1;
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
}
function show(walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const addr = wallet && wallet.addresses[addrIdx];
if (!addr) {
showFlash("That address is no longer available.");
return;
}
clear();
walletIndex = walletIdx;
addressIndex = addrIdx;
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const img = document.createElement("img");
img.src = makeBlockie(addr.address);
img.width = 48;
img.height = 48;
img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%";
blockieEl.appendChild(img);
$("export-privkey-title").textContent =
wallet.name + " — Address " + (addrIdx + 1);
const addrContainer = $("export-privkey-dot").parentElement;
addrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(addrContainer);
// Pushed here rather than by the caller: this function can return
// without navigating, and a push that happened anyway would leave an
// entry on the stack that no screen transition matches.
pushCurrentView();
showView(VIEW);
}
async function reveal() {
const password = $("export-privkey-password").value;
if (!password) {
fail("Please enter your password.");
return;
}
if (walletIndex === null) {
fail("No address is selected.");
return;
}
const wallet = state.wallets[walletIndex];
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const generation = revealGeneration;
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
// The only suspension point in this view, and the gate on the only
// place a secret is written: if the screen was left while the
// decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written.
if (!isCurrentReveal(generation)) return;
const signer = getSignerForAddress(wallet, addressIndex, secret);
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
fail("That password is incorrect. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
}
function init() {
onViewLeave(VIEW, clear);
// No wipe here: goBack() routes through showView(), which runs the
// leave hook. A per-button wipe would only cover this one path.
$("btn-export-privkey-back").addEventListener("click", () => {
goBack();
});
$("btn-export-privkey-confirm").addEventListener("click", reveal);
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (!key) return;
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
});
}
module.exports = { init, show };

View File

@@ -1,19 +1,15 @@
// Shared DOM helpers used by all views.
const { DEBUG } = require("../../shared/constants");
const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { state, saveState } = require("../../shared/state");
const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match.
const VIEWS = [
"welcome",
"add-wallet",
"import-key",
"main",
"address",
"address-token",
@@ -26,14 +22,27 @@ const VIEWS = [
"add-token",
"settings",
"delete-wallet-confirm",
"delete-address-confirm",
"settings-addtoken",
"transaction",
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
"show-phrase",
];
// Cleanup callbacks for views that hold a secret in the DOM. The view
// registers one for itself and showView() runs it whenever that view is
// navigated away from, so the secret is wiped no matter which control
// caused the navigation — "Back", the settings gear, or a jump from
// anywhere else. A per-button clear would only cover the one path.
const viewLeaveHandlers = new Map();
function onViewLeave(name, fn) {
viewLeaveHandlers.set(name, fn);
}
function $(id) {
return document.getElementById(id);
}
@@ -41,14 +50,21 @@ function $(id) {
function showError(id, msg) {
const el = $(id);
el.textContent = msg;
el.classList.remove("hidden");
el.style.visibility = "visible";
}
function hideError(id) {
$(id).classList.add("hidden");
const el = $(id);
el.textContent = "";
el.style.visibility = "hidden";
}
function showView(name) {
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
@@ -57,13 +73,89 @@ function showView(name) {
}
clearFlash();
state.currentView = name;
// A view's show() ends here, so this is where the Back path learns the
// view is no longer the blank template from index.html and must not be
// rendered a second time. See viewRouter.js.
markViewRendered(name);
saveState();
if (DEBUG) {
const banner = document.getElementById("debug-banner");
if (banner) {
banner.textContent = "DEBUG / INSECURE (" + name + ")";
updateDebugBanner(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 that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses.
//
// It answers true when it took the navigation — the view is rendered and
// shown, or its backing data was gone and it fell back — and false for a
// view the popup does not render from persisted state. Those can only be
// on the stack from this page load, because the stack is filtered on load,
// so they have already been rendered and only need unhiding.
let _renderBack = null;
function setBackRenderer(fn) {
_renderBack = 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";
}
// A popped view is landed on, not navigated to. If the popup has been
// closed and reopened since the view was pushed, nothing has ever
// rendered it in this page load and its template is still blank, so it
// has to be rendered here rather than merely unhidden.
if (_renderBack && _renderBack(target)) return;
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;
@@ -136,6 +228,20 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
return html;
}
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about. Deliberately unrounded — the rendered lines round to four
// decimals, so a dust balance displays as 0.0000 while still being real
// money at a real address. Callers that warn about holdings must ask this,
// not the rendered figure.
function addressHoldsFunds(addr) {
if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true;
for (const t of addr.tokenBalances || []) {
if (parseFloat(t.balance || "0") > 0) return true;
}
return false;
}
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
@@ -207,38 +313,47 @@ function addressTitle(address, wallets) {
// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
// Delegates to renderAddressHtml for consistent output.
function formatAddressHtml(address, ensName, maxLen, title) {
const dot = addressDotHtml(address);
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>`;
return renderAddressHtml(address, { title, ensName, maxLen });
}
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
pad(d.getSeconds()) +
tzStr
);
}
@@ -259,19 +374,132 @@ function timeAgo(timestamp) {
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) {
if (!el) return;
el.classList.remove("copy-flash-fade");
el.classList.add("copy-flash-active");
setTimeout(() => {
el.classList.remove("copy-flash-active");
el.classList.add("copy-flash-fade");
setTimeout(() => {
el.classList.remove("copy-flash-fade");
}, 275);
}, 75);
}
module.exports = {
VIEWS,
$,
showError,
hideError,
showView,
onViewLeave,
updateDebugBanner,
setBackRenderer,
pushCurrentView,
goBack,
clearViewStack,
showFlash,
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
addressHoldsFunds,
addressColor,
addressDotHtml,
escapeHtml,
addressTitle,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml,
EXT_ICON,
truncateMiddle,
isoDate,
timeAgo,

View File

@@ -9,18 +9,28 @@ const {
addressTitle,
escapeHtml,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
} = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const { canRemoveAddress } = require("../../shared/walletDelete");
const {
walletDefect,
walletDefectHtml,
} = require("../../shared/walletDefects");
const {
formatUsd,
formatAddressTotal,
getPrice,
getAddressValueUsd,
getAddressValue,
} = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -62,33 +72,16 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd;
if (subEl) {
const totalUsd = getAddressValueUsd(addr);
subEl.innerHTML =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "&nbsp;";
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
}
}
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() {
const el = $("active-address-display");
if (!el) return;
if (state.activeAddress) {
const addr = state.activeAddress;
const dot = addressDotHtml(addr);
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", () => {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
});
el.innerHTML = renderAddressHtml(state.activeAddress);
attachCopyHandlers(el);
} else {
el.textContent = "";
}
@@ -174,6 +167,7 @@ async function loadHomeTxs(ctx) {
if (allAddresses.length === 0) return;
const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
@@ -224,30 +218,34 @@ async function loadHomeTxs(ctx) {
}
}
function render(ctx) {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
renderActiveAddress();
return;
}
// The wallet list markup. Pure: it reads state and returns a string, so the
// list can be asserted on without a DOM.
function walletListHtml() {
let html = "";
state.wallets.forEach((wallet, wi) => {
const defect = walletDefect(wallet);
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd") {
// No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce
// under the standard path.
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
}
html += `</div>`;
html += walletDefectHtml(wallet);
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
const isActive = state.activeAddress === addr.address;
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
// Only where a wallet can spare the address: a wallet holding a
// single address has no remove control, because its last address
// is never removable.
const removeBtn = canRemoveAddress(wallet)
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
: "";
const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
@@ -256,10 +254,10 @@ function render(ctx) {
}
html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -270,7 +268,20 @@ function render(ctx) {
html += `</div>`;
});
container.innerHTML = html;
return html;
}
function render(ctx) {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
renderActiveAddress();
return;
}
container.innerHTML = walletListHtml();
container.querySelectorAll(".address-row").forEach((row) => {
row.addEventListener("click", async () => {
@@ -281,11 +292,7 @@ function render(ctx) {
state.activeAddress = addr;
await saveState();
render(ctx);
const runtime =
typeof browser !== "undefined"
? browser.runtime
: chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
});
});
@@ -299,6 +306,16 @@ function render(ctx) {
});
});
container.querySelectorAll(".btn-remove-address").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
ctx.showDeleteAddress(
parseInt(btn.dataset.wallet, 10),
parseInt(btn.dataset.address, 10),
);
});
});
container.querySelectorAll(".btn-add-address").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
@@ -358,6 +375,13 @@ function render(ctx) {
loadHomeTxs(ctx);
}
// The defect of the wallet the selected address belongs to, or null. Call
// after selectActiveAddress().
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
}
function selectActiveAddress() {
for (let wi = 0; wi < state.wallets.length; wi++) {
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
@@ -381,6 +405,13 @@ function init(ctx) {
showFlash("No active address selected.");
return;
}
// Before the balance check and before any password is asked for: this
// wallet cannot sign at all, so the send screen is a dead end.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr = currentAddress();
if (!addr.balance || parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
@@ -393,6 +424,7 @@ function init(ctx) {
renderSendTokenSelect(addr);
updateSendBalance();
resetSendValidation();
pushCurrentView();
showView("send");
});
@@ -405,4 +437,4 @@ function init(ctx) {
});
}
module.exports = { init, render };
module.exports = { init, render, walletListHtml };

View File

@@ -1,69 +0,0 @@
const { $, showView, showFlash } = require("./helpers");
const { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
function show() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
showView("import-key");
}
function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showFlash("Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [
{ address: addr, balance: "0.0000", tokenBalances: [] },
],
});
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
});
$("btn-import-key-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
} else {
ctx.renderWalletList();
showView("main");
}
});
}
module.exports = { init, show };

View File

@@ -2,19 +2,15 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
formatAddressHtml,
addressTitle,
attachCopyHandlers,
goBack,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { state, currentAddress, currentNetwork } = require("../../shared/state");
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() {
const addr = currentAddress();
const address = addr ? addr.address : "";
@@ -24,10 +20,8 @@ function show() {
? formatAddressHtml(address, ensName, null, title)
: "";
$("receive-address-block").dataset.full = address;
const link = address ? `https://etherscan.io/address/${address}` : "";
$("receive-etherscan-link").innerHTML = link
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
: "";
// Etherscan link is now included in formatAddressHtml via renderAddressHtml
$("receive-etherscan-link").innerHTML = "";
if (address) {
QRCode.toCanvas($("receive-qr"), address, {
width: 200,
@@ -51,37 +45,30 @@ function show() {
warningEl.textContent =
"This is an ERC-20 token. Only send " +
symbol +
" on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss.";
warningEl.classList.remove("hidden");
" on " +
currentNetwork().name +
" to this address. Sending tokens on other networks will result in permanent loss.";
warningEl.style.visibility = "visible";
} else {
warningEl.classList.add("hidden");
warningEl.textContent = "";
warningEl.style.visibility = "hidden";
}
showView("receive");
attachCopyHandlers("view-receive");
}
function init(ctx) {
$("receive-address-block").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
function init(_ctx) {
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("receive-address-block"));
}
});
$("btn-receive-back").addEventListener("click", () => {
if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
goBack();
});
}

View File

@@ -3,14 +3,17 @@
const {
$,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -113,21 +116,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) {
const upper = (t.symbol || "").toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) return false;
const legit = KNOWN_SYMBOLS.get(upper);
if (legit === null) return true;
return t.address.toLowerCase() !== legit;
}
function renderSendTokenSelect(addr) {
const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>';
@@ -135,9 +123,12 @@ function renderSendTokenSelect(addr) {
(state.fraudContracts || []).map((a) => a.toLowerCase()),
);
for (const t of addr.tokenBalances || []) {
if (isSpoofedToken(t)) continue;
if (isSpoofedSymbol(t.symbol, t.address)) continue;
if (fraudSet.has(t.address.toLowerCase())) continue;
if (state.hideLowHolderTokens && (t.holders || 0) < 1000) continue;
// An unknown holder count does not withhold a token the user holds:
// only a count the explorer actually reported as below the threshold
// does. Otherwise a missing field makes a real asset unspendable.
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
const opt = document.createElement("option");
opt.value = t.address;
opt.textContent = t.symbol;
@@ -148,24 +139,12 @@ function renderSendTokenSelect(addr) {
function updateSendBalance() {
const addr = currentAddress();
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);
let fromHtml = "";
if (title) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
if (addr.ensName) {
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
}
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;
$("send-from").innerHTML = renderAddressHtml(addr.address, {
title,
ensName: addr.ensName,
});
attachCopyHandlers($("send-from"));
const token = state.selectedToken || $("send-token").value;
if (token === "ETH") {
$("send-balance").textContent =
@@ -230,7 +209,7 @@ function init(_ctx) {
}
resolvedTo = resolved;
ensName = to;
} catch (e) {
} catch {
showFlash("Failed to resolve ENS name.");
return;
}
@@ -268,11 +247,7 @@ function init(_ctx) {
$("btn-send-back").addEventListener("click", () => {
$("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
goBack();
});
}

View File

@@ -1,11 +1,37 @@
const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
const {
$,
showView,
updateDebugBanner,
showFlash,
escapeHtml,
flashCopyFeedback,
goBack,
pushCurrentView,
} = require("./helpers");
const { applyTheme } = require("../theme");
const {
DUST_THRESHOLD_MESSAGE,
parseDustThresholdGwei,
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_DATE,
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const { notify } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) {
const container = $(containerId);
@@ -33,7 +59,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
}
}
await saveState();
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key);
});
});
@@ -77,17 +103,34 @@ function renderWalletListSettings() {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
// Key and xprv wallets have no recovery phrase, so they are never
// offered the action at all.
if (walletHasRecoveryPhrase(wallet)) {
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
}
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</span>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
pushCurrentView();
deleteWallet.show(idx);
});
});
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
// No pushCurrentView() here: showPhrase.show() refuses
// non-HD wallets and pushes only when it navigates.
showPhrase.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
@@ -124,10 +167,33 @@ function renderWalletListSettings() {
function show() {
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId;
renderTrackedTokens();
renderSiteLists();
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");
}
@@ -142,6 +208,7 @@ function renderSiteLists() {
function init(ctx) {
deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();
@@ -167,9 +234,12 @@ function init(ctx) {
showFlash("Endpoint returned error: " + json.error.message);
return;
}
if (json.result !== ETHEREUM_MAINNET_CHAIN_ID) {
const net = currentNetwork();
if (json.result !== net.chainId) {
showFlash(
"Wrong network (expected mainnet, got chain " +
"Wrong network (expected " +
net.name +
", got chain " +
json.result +
").",
);
@@ -208,12 +278,34 @@ function init(ctx) {
showFlash("Saved.");
});
const networkSelect = $("settings-network");
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").addEventListener("change", async () => {
state.showZeroBalanceTokens = $("settings-show-zero-balances").checked;
await saveState();
});
$("settings-theme").value = state.theme;
$("settings-theme").addEventListener("change", async () => {
state.theme = $("settings-theme").value;
await saveState();
applyTheme(state.theme);
});
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
await saveState();
});
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
$("settings-hide-low-holders").addEventListener("change", async () => {
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
@@ -234,11 +326,24 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => {
const val = parseInt($("settings-dust-threshold").value, 10);
if (!isNaN(val) && val >= 0) {
const val = parseDustThresholdGwei($("settings-dust-threshold").value);
// Rejected input is never coerced. The field is put back to the
// stored threshold so it never shows a value the wallet is not
// using, and the message says what the field wants so the snap-back
// is explained rather than silent.
if (val === null) {
showFlash(DUST_THRESHOLD_MESSAGE);
} else {
state.dustThresholdGwei = val;
await saveState();
}
$("settings-dust-threshold").value = state.dustThresholdGwei;
});
$("settings-utc-timestamps").checked = state.utcTimestamps;
$("settings-utc-timestamps").addEventListener("change", async () => {
state.utcTimestamps = $("settings-utc-timestamps").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
@@ -248,9 +353,68 @@ function init(ctx) {
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", () => {
ctx.renderWalletList();
showView("main");
goBack();
});
}

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash } = require("./helpers");
const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -73,7 +73,8 @@ function renderDropdown() {
function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").classList.add("hidden");
$("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden";
renderTop10();
renderDropdown();
showView("settings-addtoken");
@@ -83,7 +84,7 @@ function init(_ctx) {
ctx = _ctx;
$("btn-settings-addtoken-back").addEventListener("click", () => {
ctx.showSettingsView();
goBack();
});
$("btn-settings-addtoken-select").addEventListener("click", async () => {
@@ -129,7 +130,7 @@ function init(_ctx) {
}
const infoEl = $("settings-addtoken-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(addr, state.rpcUrl);
@@ -143,7 +144,8 @@ function init(_ctx) {
await saveState();
showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = "";
infoEl.classList.add("hidden");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
@@ -151,7 +153,8 @@ function init(_ctx) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
});
}

View File

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

View File

@@ -5,31 +5,41 @@ const {
$,
showView,
showFlash,
addressDotHtml,
flashCopyFeedback,
addressTitle,
addressDotHtml,
escapeHtml,
isoDate,
timeAgo,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
goBack,
} = require("./helpers");
const { state } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
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;
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
/**
* Determine a human-readable transaction type string from tx fields.
*/
function getTransactionType(tx) {
if (!tx.to) return "Contract Creation";
if (tx.direction === "contract") {
if (tx.directionLabel === "Swap") return "Swap";
if (
tx.method === "approve" ||
tx.directionLabel === "Approve" ||
tx.method === "setApprovalForAll"
)
return "Token Approval";
return "Contract Call";
}
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
return "Native ETH Transfer";
}
function blockieHtml(address) {
@@ -37,44 +47,16 @@ function blockieHtml(address) {
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 (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center"` +
`>${EXT_ICON}</a>`
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
);
}
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) {
const link = `https://etherscan.io/tx/${hash}`;
const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -98,6 +80,7 @@ function show(tx) {
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
contractAddress: tx.contractAddress || null,
},
};
render();
@@ -134,47 +117,162 @@ function render() {
nativeEl.parentElement.classList.add("hidden");
}
// Show type label for contract interactions (Swap, Execute, etc.)
// Always show transaction type as the first field
const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
if (typeSection && typeEl) {
typeEl.textContent = getTransactionType(tx);
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
// Token contract address (for ERC-20 transfers)
const tokenContractSection = $("tx-detail-token-contract-section");
const tokenContractEl = $("tx-detail-token-contract");
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = `${currentNetwork().explorerUrl}/token/${tx.contractAddress}`;
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {
tokenContractSection.classList.add("hidden");
}
}
// Hide calldata and raw data sections; always fetch full tx details
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
// Hide on-chain detail sections until populated
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
"tx-detail-network-section",
]) {
const el = $(id);
if (el) el.classList.add("hidden");
}
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
loadFullTxDetails(tx.hash, tx.to);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction");
attachCopyHandlers("view-transaction");
}
document
.getElementById("view-transaction")
.querySelectorAll("[data-copy]")
.forEach((el) => {
function showDetailField(sectionId, contentId, value) {
const section = $(sectionId);
const el = $(contentId);
if (!section || !el) return;
el.innerHTML = copyableHtml(value, "");
section.classList.remove("hidden");
}
function populateOnChainDetails(txData) {
// Block number
if (txData.block_number != null) {
const blockLink = `${currentNetwork().explorerUrl}/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
blockEl.innerHTML =
copyableHtml(String(txData.block_number), "") +
etherscanLinkHtml(blockLink);
blockSection.classList.remove("hidden");
}
}
// Nonce
if (txData.nonce != null) {
showDetailField(
"tx-detail-nonce-section",
"tx-detail-nonce",
String(txData.nonce),
);
}
// Transaction fee
const feeWei = txData.fee?.value || txData.tx_fee;
if (feeWei) {
const feeEth = formatEther(String(feeWei));
showDetailField(
"tx-detail-fee-section",
"tx-detail-fee",
feeEth + " ETH",
);
}
// Gas price
const gasPrice = txData.gas_price;
if (gasPrice) {
const gwei = formatUnits(String(gasPrice), "gwei");
showDetailField(
"tx-detail-gasprice-section",
"tx-detail-gasprice",
gwei + " Gwei",
);
}
// Gas used
const gasUsed = txData.gas_used;
if (gasUsed) {
showDetailField(
"tx-detail-gasused-section",
"tx-detail-gasused",
String(gasUsed),
);
}
// 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
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const section = $(id);
if (!section) continue;
section.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
}
async function loadCalldata(txHash, toAddress) {
async function loadFullTxDetails(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -189,6 +287,10 @@ async function loadCalldata(txHash, toAddress) {
);
if (!resp.ok) return;
const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
@@ -204,19 +306,14 @@ async function loadCalldata(txHash, toAddress) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then dot + address + Etherscan link
const dot = addressDotHtml(d.address);
// Token entry: show symbol on its own line, then address via shared renderer
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
}
const etherscanUrl = `https://etherscan.io/token/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
detailsHtml += renderAddressHtml(d.address);
} else if (d.address) {
// Protocol/contract entry: show name + Etherscan link
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>`;
detailsHtml += renderAddressHtml(d.address);
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
@@ -243,26 +340,18 @@ async function loadCalldata(txHash, toAddress) {
// Bind copy handlers for new elements (including raw data now outside section)
const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) {
container.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
attachCopyHandlers(container);
}
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
}
}
// The ctx this view is initialized with is unused: this module is the leaf of
// the navigation, and the other views reach it through their own ctx.
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {
if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
goBack();
});
}

View File

@@ -3,28 +3,49 @@
const {
$,
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
clearViewStack,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
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>`;
// Receipt poll cadence and the deadline after which the wait is reported as
// a timeout. Both are documented in the WaitTx section of README.md.
const POLL_INTERVAL_MS = 10000;
const TIMEOUT_MS = 60000;
// How many receipt lookups may fail in a row before the wait is ended and
// the failure reported. A lookup that throws says nothing about the
// transaction, so one must not end the wait — but an RPC that never answers
// (a mistyped URL in settings is the ordinary case) must not leave the wait
// running forever either, least of all a persisted one that every popup
// open would resume. Six is 60 seconds at the poll cadence: the same
// patience the confirmation deadline gets. Any lookup that answers, with a
// receipt or with null, resets the count.
const MAX_CONSECUTIVE_LOOKUP_FAILURES = 6;
let ctx;
let elapsedTimer = null;
let pollTimer = null;
function clearTimers() {
// Identifies the wait currently on screen. Bumped by endWait(), so a timer
// callback or an in-flight receipt lookup that outlives its wait can tell
// that it is stale and leave the current view alone. Without it, a receipt
// resolving after the wait has ended renders over whatever view replaced it.
let waitId = 0;
// End the wait on screen: stop its timers and invalidate its pending async
// work. Called on receipt, on timeout, when a new wait starts, and when the
// user navigates away.
function endWait() {
waitId++;
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
@@ -36,43 +57,28 @@ function clearTimers() {
}
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);
if (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>`;
return renderAddressHtml(address, { title });
}
function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer break-all" data-copy="${escapeHtml(hash)}">${escapeHtml(hash)}</span>` +
extLink
);
const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
function attachCopyHandlers(viewId) {
document
.getElementById(viewId)
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = `${currentNetwork().explorerUrl}/block/${num}`;
return copyableHtml(num) + etherscanLinkHtml(link);
}
function showWait(txInfo, txHash) {
clearTimers();
// Render the wait view and start polling for the receipt. broadcastTime is
// when the transaction was broadcast, which is what the elapsed counter and
// the timeout deadline are both measured from; pollNow runs one lookup
// immediately instead of waiting a full poll interval.
function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait();
const id = waitId;
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
@@ -80,41 +86,130 @@ function showWait(txInfo, txHash) {
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx");
const broadcastTime = Date.now();
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
// Persisted so closing and reopening the popup resumes this wait
// instead of silently abandoning it.
state.viewData = {
pendingWait: {
txInfo: txInfo,
hash: txHash,
broadcastTime: broadcastTime,
},
};
elapsedTimer = setInterval(() => {
function renderElapsed() {
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
$("wait-tx-status").textContent =
"Waiting for confirmation... " + elapsed + "s";
}
renderElapsed();
elapsedTimer = setInterval(() => {
if (id !== waitId) return;
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl);
pollTimer = setInterval(async () => {
let consecutiveFailures = 0;
async function poll() {
if (id !== waitId) return;
let receipt = null;
let answered = true;
try {
const receipt = await provider.getTransactionReceipt(txHash);
if (receipt) {
showSuccess(txInfo, txHash, receipt.blockNumber);
}
receipt = await provider.getTransactionReceipt(txHash);
} catch (e) {
// A thrown lookup means "no answer this tick", not "no
// receipt": the RPC failed, the chain said nothing. Declaring
// the timeout off it would report a confirmed transaction as
// failed — which matters most on a resumed wait, where the
// first poll is already past the deadline.
answered = false;
log.errorf("poll receipt failed:", e.message);
}
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
if (elapsed >= 60) {
// The lookup is async: the wait may have ended while it was in
// flight, in which case this result must not touch the view.
if (id !== waitId) return;
// Exactly one outcome per wait. A receipt wins even on the tick
// that crosses the deadline, because the transaction did confirm.
if (receipt) {
showSuccess(txInfo, txHash, receipt.blockNumber);
return;
}
if (!answered) {
consecutiveFailures++;
// The failure is the user's news, and it is a different fact
// from "the transaction did not confirm" — the chain was never
// asked. Ending the wait here is what keeps it bounded and
// gives the user a Done button to leave by.
if (consecutiveFailures >= MAX_CONSECUTIVE_LOOKUP_FAILURES) {
showError(
txInfo,
txHash,
"The network could not be reached to check this transaction — " +
MAX_CONSECUTIVE_LOOKUP_FAILURES +
" lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
);
}
// Otherwise keep polling: the next tick may answer.
return;
}
consecutiveFailures = 0;
if (Date.now() - broadcastTime >= TIMEOUT_MS) {
showError(
txInfo,
txHash,
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
);
}
}, 10000);
}
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
showView("wait-tx");
if (pollNow) poll();
}
function showWait(txInfo, txHash) {
startWait(txInfo, txHash, Date.now(), false);
}
// Resume a wait persisted by a previous popup session. The deadline still
// runs from the original broadcast, so a wait that has already outlived it
// resolves on the immediate first poll rather than restarting the clock.
// Returns false when there is nothing resumable to resume. Every field
// startWait() goes on to use is validated, not just the presence of the
// containers: txInfo.to reaches addressTitle(), which calls
// address.toLowerCase(), and txInfo.amount is rendered into the summary, so
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
const w = d.pendingWait;
if (!w.hash) return false;
// typeof [] is "object", so an array passes an object check.
const info = w.txInfo;
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), and both fields render harmlessly when empty, so refusing it
// would abandon a wait the live path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false;
}
startWait(w.txInfo, w.hash, w.broadcastTime, true);
return true;
}
function showSuccess(txInfo, txHash, blockNumber) {
clearTimers();
endWait();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
state.viewData = {
@@ -134,10 +229,6 @@ function tokenLabel(address) {
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
@@ -189,7 +280,7 @@ function renderSuccess() {
$("success-tx-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").textContent = String(d.blockNumber);
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
@@ -206,7 +297,7 @@ function renderSuccess() {
}
function showError(txInfo, txHash, message) {
clearTimers();
endWait();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
state.viewData = {
@@ -242,14 +333,23 @@ function isApprovalPopup() {
}
function navigateBack() {
// Nothing should still be polling by now, but leaving a view is the
// point at which its timers must be gone.
endWait();
if (isApprovalPopup()) {
window.close();
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) {
ctx.showAddressToken();
state.viewStack.push("address");
require("./addressToken").show();
} else {
ctx.showAddressDetail();
require("./addressDetail").show();
}
}
@@ -260,4 +360,12 @@ function init(_ctx) {
$("btn-error-tx-done").addEventListener("click", navigateBack);
}
module.exports = { init, showWait, showError, renderSuccess, renderError };
module.exports = {
init,
showWait,
restoreWait,
endWait,
showError,
renderSuccess,
renderError,
};

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

134
src/shared/alarms.js Normal file
View File

@@ -0,0 +1,134 @@
// Periodic scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// which takes every setInterval/setTimeout with it. The extension alarms API
// is the mechanism that survives: the browser holds the schedule and wakes
// the worker to deliver onAlarm. Firefox MV2 runs a persistent background
// page where timers would survive, but alarms behave identically there, so
// both targets share this path and both manifests declare the "alarms"
// permission.
//
// Periods are whole minutes at or above the browser-enforced one-minute
// minimum, so nothing here is silently clamped to a slower cadence.
//
// Trap for anyone changing a period: each job also carries a freshness guard
// that can veto its own scheduled tick. A guard timed to the alarm period
// halves the real cadence, because the guard is measured from when the last
// run finished and the alarm fires one run-duration earlier than that. Every
// guard must therefore either be strictly shorter than the period it gates or
// be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js.
const { alarmsApi } = require("./browserApi");
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
// Alarms this extension used to create and no longer has a handler for. A
// browser keeps an alarm until something clears it, so a job that is deleted
// from the code goes on waking the service worker on its old schedule forever,
// on every install that ever ran the version which created it. Removing the job
// means removing the alarm, so retired names are listed here and cleared on
// every start until the installs that carry them are long gone.
const OBSOLETE_ALARMS = [
// The 24-hour phishing blocklist refresh, retired when the runtime fetch
// was removed and the list became purely build-time vendored.
"autistmask-phishing-refresh",
];
const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
// alarmsApi() resolves on use rather than at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// this module. It returns null where the API is absent, which is why every
// entry point below degrades instead of throwing.
/**
* Create an alarm unless one with the requested period already exists.
*
* The existence check is load-bearing: creating an alarm resets its schedule,
* and this runs on every worker wake. Creating unconditionally would push the
* next fire time out on every incoming message, so a busy extension would
* never see the alarm fire at all.
*
* The period comparison is equally load-bearing in the other direction: an
* alarm created by an older version keeps its old period forever unless a
* changed constant re-creates it, so a period edit would never reach an
* existing install. Re-creating on a period change happens once and then
* settles into the existence check above.
*
* @param {string} name
* @param {number} periodInMinutes
* @returns {Promise<boolean>} true if the alarm was created by this call.
*/
async function ensureAlarm(name, periodInMinutes) {
const api = alarmsApi();
if (!api) return false;
const period = Math.max(periodInMinutes, MIN_ALARM_PERIOD_MINUTES);
const existing = await api.get(name);
if (existing && existing.periodInMinutes === period) return false;
api.create(name, {
periodInMinutes: period,
delayInMinutes: period,
});
return true;
}
/**
* Clear every alarm this extension no longer handles.
*
* @returns {Promise<string[]>} the retired alarms this call actually cleared.
*/
async function clearObsoleteAlarms() {
const api = alarmsApi();
if (!api || !api.clear) return [];
const cleared = [];
for (const name of OBSOLETE_ALARMS) {
if (await api.clear(name)) cleared.push(name);
}
return cleared;
}
/**
* Ensure the recurring background jobs are scheduled, and that retired ones are
* not. Safe to call on every worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, cleared: string[]}>} which alarms this
* call had to create, and which retired ones it removed.
*/
async function ensureRecurringAlarms() {
const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
);
const cleared = await clearObsoleteAlarms();
return { balance, cleared };
}
/**
* Register per-alarm handlers. One listener dispatches by alarm name so the
* worker only ever installs a single onAlarm listener.
*
* @param {Object<string, function>} handlers
* @returns {boolean} true if the listener was installed.
*/
function registerAlarmHandlers(handlers) {
const api = alarmsApi();
if (!api || !api.onAlarm) return false;
api.onAlarm.addListener((alarm) => {
const handler = handlers[alarm && alarm.name];
if (handler) handler();
});
return true;
}
module.exports = {
BALANCE_REFRESH_ALARM,
OBSOLETE_ALARMS,
MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES,
clearObsoleteAlarms,
ensureAlarm,
ensureRecurringAlarms,
registerAlarmHandlers,
};

213
src/shared/approvalTx.js Normal file
View File

@@ -0,0 +1,213 @@
// Preparation of the transaction an approval screen displays.
//
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
// The nonce, the gas limit and the fees have to be filled in from the network
// before anything can be signed, and whoever fills them in decides what the
// user is shown. That work used to happen in the popup, after the user had
// already approved: the numbers on the approval screen came from the popup and
// were compared against nothing, so a compromised popup could display one fee
// and sign another, and the ceilings in approvalVerify.js were all that stood
// between the user and a fee that hands the validator the balance.
//
// So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and
// verifies the signed artifact against that same object — the popup is handed
// a finished transaction and signs it as given. Every field the user reads is
// then a field that is compared.
//
// The cost is an RPC round trip before the approval window exists. Nothing is
// displayed while it is in flight, and a failure — an unreachable node, a
// reverting gas estimate, a transaction type this wallet does not sign, a fee
// past the ceilings — means no approval and no window at all: the error goes
// back to the requesting page, which is where the user's click came from. That
// is deliberate. The alternative, opening the window first and populating
// behind a spinner, needs a pending approval that exists before it can be
// displayed or signed, and a half-initialised approval is exactly the state
// the settle interlock in the background exists to keep out of that record.
// The failure also lands earlier than it used to rather than later: the same
// estimate previously failed after the user had typed their password.
const {
VoidSigner,
accessListify,
getAddress,
getBytes,
hexlify,
toQuantity,
} = require("ethers");
const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
// page. Without a bound a hung RPC endpoint leaves the dApp's promise pending
// forever with nothing on screen to explain it; ethers' own request timeout is
// minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page. Anything else is dropped rather than
// passed to ethers: the object is page-controlled, and a future ethers that
// learns to carry a new transaction field must not start picking one up out of
// it without this module knowing.
const REQUEST_FIELDS = [
"to",
"value",
"data",
"nonce",
"gasLimit",
"gasPrice",
"maxFeePerGas",
"maxPriorityFeePerGas",
"chainId",
"accessList",
"type",
];
class ApprovalPrepareError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalPrepareError";
}
}
function fail(message) {
return new ApprovalPrepareError(message);
}
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// These strings reach the user through the requesting page, so they are full
// sentences even when the tail of one came from ethers or from the node.
function sentence(text) {
return /[.!?]$/.test(text) ? text : text + ".";
}
// Reject a promise that has taken too long, and never leave the timer behind.
async function withTimeout(promise, ms, message) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(fail(message)), ms);
}),
]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
// The page's request, reduced to the fields this wallet acts on.
function requestFrom(txParams, from) {
const request = { from: getAddress(from) };
for (const key of REQUEST_FIELDS) {
if (present(txParams[key])) request[key] = txParams[key];
}
if (
present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type))
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
);
}
return request;
}
// Turn a populated transaction into the object that crosses to the popup, is
// displayed, and is compared with the signed artifact. It carries exactly the
// fields its type serializes, plus the address it is to be signed by, and
// every quantity as a hex string: extension messaging is JSON, which has no
// bigint, and a field that did not survive the trip would be a field the user
// was shown and nothing compared.
function serializeApprovedTx(populated, from) {
const type = Number(populated.type);
if (!ALLOWED_TX_TYPES.includes(type)) {
throw fail(
"This transaction would have to be sent as a type this wallet does not sign.",
);
}
const approved = { type, from: getAddress(from) };
for (const key of SERIALIZED_FIELDS[type]) {
if (key === "to") {
approved.to = present(populated.to)
? getAddress(populated.to)
: null;
} else if (key === "data") {
approved.data = present(populated.data)
? hexlify(getBytes(populated.data))
: "0x";
} else if (key === "accessList") {
approved.accessList = accessListify(populated.accessList || []);
} else if (key === "value") {
approved.value = toQuantity(populated.value || 0);
} else if (!present(populated[key])) {
// Unreachable while populateTransaction() fills every quantity of
// the type it produced. If it ever does not, the approval must not
// be raised: an unfixed quantity is one the artifact cannot be
// checked against.
throw fail(
"The transaction could not be prepared: the network did not supply a " +
key +
".",
);
} else {
approved[key] = toQuantity(populated[key]);
}
}
return approved;
}
// Populate the transaction a site asked for, as the address it will be signed
// by, and return the object to display, sign and verify against. Throws with a
// full sentence when no approval can be raised.
async function prepareApprovalTx(provider, from, txParams) {
if (!present(from)) {
throw fail("There is no active address to send this transaction from.");
}
const request = requestFrom(txParams || {}, from);
let populated;
try {
// The sequence ethers' own sendTransaction() runs internally, so the
// nonce, gas, fee and chain id are populated exactly as they were when
// the popup did this. VoidSigner cannot sign, which is the point: the
// background prepares, the popup signs.
populated = await withTimeout(
new VoidSigner(getAddress(from), provider).populateTransaction(
request,
),
POPULATE_TIMEOUT_MS,
"The transaction could not be prepared: the network did not answer in time.",
);
} catch (e) {
if (e instanceof ApprovalPrepareError) throw e;
throw fail(
sentence(
"The transaction could not be prepared: " +
(e.shortMessage ||
e.message ||
"the network did not answer"),
),
);
}
const approved = serializeApprovedTx(populated, from);
// The backstop, applied before the user is shown anything rather than
// after they have approved it: what is displayed here is what gets signed,
// so an RPC node reporting an absurd fee has to be refused here.
assertWithinCeilings(approved);
return approved;
}
module.exports = {
prepareApprovalTx,
serializeApprovedTx,
ApprovalPrepareError,
POPULATE_TIMEOUT_MS,
REQUEST_FIELDS,
};

View File

@@ -0,0 +1,778 @@
// 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.
//
// What the artifact is checked against is the transaction the background
// populated and the popup displayed (see approvalTx.js), not the request the
// dApp made. The two differ in every field a dApp normally leaves out — nonce,
// gas limit, fees — and those are the fields the user reads off the approval
// screen, so comparing against the request would leave the numbers on screen
// vouched for by nothing.
//
// The check is an allowlist, in both directions, because a denylist cannot be
// correct against a transaction format that keeps gaining fields:
//
// - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type
// adds a field with consequences of its own — EIP-7702's authorizationList
// rewrites the code at the signer's own account, EIP-4844's blob
// commitments carry a separate fee — and a check that enumerates the fields
// it refuses admits every one of them by default.
// - after the per-field comparisons, the artifact is rebuilt from those
// checked fields and nothing else, and the two are compared byte for byte.
// Anything the artifact carries that this module does not name is absent
// from the rebuild and changes the bytes, so the final assertion is that
// the artifact *is* the approved transaction, not merely that it is not one
// of the tampered shapes that were thought of.
// - every comparison runs against the decode, but the string handed to
// broadcastTransaction() is the artifact. So the artifact is also required
// to be the canonical re-encoding of its own decode, which is what makes
// the checked transaction and the broadcast bytes the same object rather
// than two things that merely decode alike.
//
// Every consequential field is compared, and a mismatch is a refusal to act,
// never a warning: what the user approved is what gets broadcast, or nothing
// does.
//
// The approved transaction is required to fix every field its type serializes,
// so there is no "the approval did not say" branch to fall through: a quantity
// the approval does not carry is a refusal, because an artifact that cannot be
// compared with what was displayed has not been checked. The chain id is
// checked against the selected network as well as against the approval, which
// is what makes a cross-chain replay impossible.
//
// Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp.
const {
Transaction,
accessListify,
getAddress,
getBytes,
verifyMessage,
verifyTypedData,
} = require("ethers");
// The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
// be an artifact of an approval this wallet raised.
const ALLOWED_TX_TYPES = [0, 1, 2];
// The serialized fields of each allowed type, which is also the complete set
// of fields the checks below compare or bound. The artifact is rebuilt from
// exactly these at the end of verification and compared byte for byte, so a
// field outside this table cannot ride along unexamined.
const SERIALIZED_FIELDS = {
0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"],
1: [
"chainId",
"nonce",
"gasPrice",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
2: [
"chainId",
"nonce",
"maxPriorityFeePerGas",
"maxFeePerGas",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
};
// Fields no allowed type may carry. The type allowlist already excludes every
// type that defines them, and the structural check at the end of verification
// would catch them anyway; they are named here so that an artifact carrying
// one is refused with a message that says what it was.
const FORBIDDEN_FIELDS = [
{
key: "authorizationList",
message:
"The signed transaction would hand the signing account over to another contract, which was not approved.",
},
{
key: "blobVersionedHashes",
message:
"The signed transaction carries blob commitments, which were not approved.",
},
{
key: "blobs",
message:
"The signed transaction carries blobs, which were not approved.",
},
{
key: "maxFeePerBlobGas",
message:
"The signed transaction carries a blob gas fee, which was not approved.",
},
];
// Absolute ceilings — a BACKSTOP, not the primary control.
//
// The primary control is equality: every field of the artifact is compared
// with the populated transaction the user was shown, so nothing the popup
// signs can differ from the screen. What equality cannot bound is the
// populated transaction itself, which is built from what the configured RPC
// node answered — a node that reports an absurd fee gets that fee displayed,
// and a user who does not read the fee line would approve it. These ceilings
// bound that, and they are therefore applied where the transaction is
// populated (approvalTx.js) as well as here.
//
// Above the block gas limit of every supported network (see networks.js), so
// no transaction that could ever be included is refused by it.
const MAX_GAS_LIMIT = 100000000n;
// 100,000 gwei per gas: orders of magnitude above the highest fee either
// supported network has produced, and low enough to catch a fee that would
// hand the validator the balance.
const MAX_FEE_PER_GAS = 100000000000000n;
// A refusal to act on an artifact: it is not the thing that was approved, so
// the approval it was offered against is spent and must not be retried. Every
// throw in this module is one of these; the background distinguishes them from
// transient failures (a busy node, a failed broadcast), which leave the
// approval standing so the user can try again.
class ApprovalMismatchError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalMismatchError";
this.approvalMismatch = true;
}
}
function refuse(message) {
return new ApprovalMismatchError(message);
}
// Whether a signing failure leaves the approval usable. Anything that is not a
// mismatch is the user's to correct and retry.
function failureIsRetryable(err) {
return !(err && err.approvalMismatch === true);
}
// 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();
}
}
// Whether the approval fixed a value for a field at all.
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// Whether a field carries anything at all. An empty array is nothing: ethers
// reports an absent access list on a type 2 transaction as `[]`.
function carriesValue(v) {
if (!present(v)) return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}
// Normalize a quantity that must be present, refusing anything that is not a
// number: an approval carrying junk in a fee field cannot be compared, and an
// uncomparable field is a refusal rather than a pass.
function normalizeQuantity(v, label) {
try {
return BigInt(v);
} catch {
throw refuse(
"The approved " +
label +
" is not a number, so it cannot be" +
" compared with the signed transaction.",
);
}
}
// Normalize a transaction value (hex string, decimal string, number or
// bigint) to a bigint. An absent value is zero, matching ethers. The value is
// page-controlled, so it goes through the same refusal as every other
// quantity rather than throwing a raw BigInt conversion error.
function normalizeValue(v) {
if (!present(v)) return 0n;
return normalizeQuantity(v, "value");
}
// Normalize an access list to a comparable string. An absent or empty list is
// the empty string, so absent and `[]` are the same thing.
function normalizeAccessList(v) {
if (!carriesValue(v)) return "";
let list;
try {
list = accessListify(v);
} catch {
throw refuse(
"The approved access list is not a valid access list, so it cannot be compared with the signed transaction.",
);
}
return list
.map(
(entry) =>
String(entry.address).toLowerCase() +
":" +
entry.storageKeys.map((k) => String(k).toLowerCase()).join(","),
)
.join(";");
}
// 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();
}
// How each field of an approved transaction is compared with the artifact.
// There is an entry here for every field any allowed type serializes — a test
// pins that against SERIALIZED_FIELDS — so the comparison loop covers the
// whole of what gets signed and cannot silently skip a field for want of a
// comparator.
//
// `kind` decides how the two sides are made comparable. A `quantity` must be
// fixed by the approval: it is one of the numbers on the approval screen, and
// an absent one means the artifact cannot be checked against what was
// displayed. `to`, `value`, `data` and `accessList` have canonical absent
// forms — contract creation, zero, "0x" and the empty list — so they are
// normalized on both sides instead.
const APPROVED_FIELDS = {
chainId: {
kind: "quantity",
label: "network",
message:
"The signed transaction is for a different network than the one that was approved.",
},
nonce: {
kind: "quantity",
label: "nonce",
message: "The signed transaction does not carry the approved nonce.",
},
gasLimit: {
kind: "quantity",
label: "gas limit",
message:
"The signed transaction does not carry the approved gas limit.",
},
gasPrice: {
kind: "quantity",
label: "gas price",
message:
"The signed transaction does not carry the approved gas price.",
},
maxFeePerGas: {
kind: "quantity",
label: "maximum fee per gas",
message:
"The signed transaction does not carry the approved maximum fee per gas.",
},
maxPriorityFeePerGas: {
kind: "quantity",
label: "maximum priority fee per gas",
message:
"The signed transaction does not carry the approved maximum priority fee per gas.",
},
to: {
kind: "address",
label: "recipient",
message:
"The signed transaction does not go to the approved recipient.",
},
value: {
kind: "value",
label: "value",
message: "The signed transaction does not carry the approved value.",
},
data: {
kind: "data",
label: "call data",
message:
"The signed transaction does not carry the approved call data.",
},
accessList: {
kind: "accessList",
label: "access list",
message:
"The signed transaction does not carry the approved access list.",
},
};
// Compare one field of the artifact with the approved transaction. A field
// with no entry in the table above is refused rather than skipped: the loop
// below runs over the fields the type serializes, so an unmatched key means
// something that gets signed has no comparator at all.
function assertFieldMatches(key, parsed, approvedTx) {
const field = APPROVED_FIELDS[key];
if (!field) {
throw refuse(
"The signed transaction carries a field this wallet cannot compare with the approval.",
);
}
switch (field.kind) {
case "quantity": {
if (!present(approvedTx[key])) {
throw refuse(
"The approved transaction fixes no " +
field.label +
", so the signed transaction cannot be checked" +
" against what was shown.",
);
}
const approved = normalizeQuantity(approvedTx[key], field.label);
if (normalizeQuantity(parsed[key], field.label) !== approved) {
throw refuse(field.message);
}
return;
}
case "address":
if (!sameAddress(parsed[key], approvedTx[key])) {
throw refuse(field.message);
}
return;
case "value":
if (normalizeValue(parsed[key]) !== normalizeValue(approvedTx[key]))
throw refuse(field.message);
return;
case "data":
if (normalizeData(parsed[key]) !== normalizeData(approvedTx[key]))
throw refuse(field.message);
return;
default:
if (
normalizeAccessList(parsed[key]) !==
normalizeAccessList(approvedTx[key])
) {
throw refuse(field.message);
}
}
}
// The ceilings, applied to a transaction that is either about to be displayed
// or about to be broadcast. See MAX_GAS_LIMIT above for what they are for:
// they bound what the RPC node can talk this wallet into showing the user,
// which is the one thing comparing the artifact with the screen cannot do.
function assertWithinCeilings(tx) {
if (
present(tx.gasLimit) &&
normalizeQuantity(tx.gasLimit, "gas limit") > MAX_GAS_LIMIT
) {
throw refuse(
"The signed transaction sets a gas limit no network this wallet supports can accept.",
);
}
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
if (!present(tx[key])) continue;
if (normalizeQuantity(tx[key], "fee per gas") > MAX_FEE_PER_GAS) {
throw refuse(
"The signed transaction sets a fee per gas far above any plausible value.",
);
}
}
}
// Refuse a field only a transaction type this wallet does not sign can carry.
// The type allowlist keeps these unreachable in production, which is exactly
// what they are for; it also means nothing else exercises them, so this is
// exported and tested on its own rather than left to be believed.
function assertNoForbiddenFields(parsed) {
for (const field of FORBIDDEN_FIELDS) {
if (carriesValue(parsed[field.key])) throw refuse(field.message);
}
}
// Closing structural check. Rebuild the transaction from the fields the
// comparisons cover, and nothing else, then compare the unsigned bytes. Every
// field carried by the artifact but absent from the rebuild changes the
// serialization, so this refuses anything this module does not account for —
// including a field a future ethers learns to parse onto an allowed type —
// instead of waving it through by not naming it. Also exported for its own
// test: nothing reachable today can make the bytes differ.
function assertNothingUnchecked(parsed) {
let rebuilt;
try {
const fields = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
fields[key] = parsed[key];
}
rebuilt = Transaction.from(fields);
} catch {
throw refuse(
"The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.",
);
}
if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) {
throw refuse(
"The signed transaction carries data beyond the fields that were checked against the approval.",
);
}
}
// The other half of the closing check, and the one that makes it bind on the
// bytes that actually leave: every comparison above runs against the decode,
// so on its own the rebuild proves only that the transaction ethers understood
// is the approved one. What the background hands to broadcastTransaction() is
// the artifact string itself. Requiring the artifact to be exactly the
// canonical re-encoding of its own decode closes the gap between the two —
// no encoding the decoder normalizes away (a leading zero byte on an RLP
// quantity, say) can differ from what was checked. Hex case is not part of the
// encoding, so only that is normalized before comparing.
function assertCanonicalBytes(parsed, rawSignedTx) {
if (parsed.serialized !== String(rawSignedTx).toLowerCase()) {
throw refuse(
"The signed transaction is not encoded canonically, so the bytes that would be broadcast are not the bytes that were checked.",
);
}
}
// Assert that a raw signed transaction is the transaction the user approved,
// signed by the address the approval was raised for, on the network that is
// selected. Returns the parsed ethers Transaction on success, throws
// otherwise.
//
// `approvedTx` is the populated transaction the approval screen displayed, and
// `expectedFrom` is the address that was active when the approval was raised —
// not whichever address is active now. An address switch between approval and
// signing therefore refuses here rather than producing a transaction from an
// account the approval did not name.
function verifySignedTx(
rawSignedTx,
approvedTx,
expectedFrom,
selectedChainId,
) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw refuse("The signed transaction is missing or malformed.");
}
// Nothing to compare against is a refusal like any other: an approval that
// does not carry the transaction it displayed cannot vouch for one.
if (!approvedTx || typeof approvedTx !== "object") {
throw refuse(
"There is no approved transaction to check the signed transaction against.",
);
}
let parsed;
try {
parsed = Transaction.from(rawSignedTx);
} catch {
throw refuse("The signed transaction could not be decoded.");
}
if (!parsed.from) {
throw refuse("The signed transaction carries no valid signature.");
}
if (!sameAddress(parsed.from, expectedFrom)) {
throw refuse(
"The signed transaction was signed by a different address than the one that was approved.",
);
}
// Before any field is looked at: the type decides which fields exist at
// all, so an unrecognised type is refused outright rather than compared
// field by field against an approval that cannot describe it.
if (!ALLOWED_TX_TYPES.includes(parsed.type)) {
throw refuse(
"The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.",
);
}
assertNoForbiddenFields(parsed);
// The selected network, not the artifact, is the authority on which chain
// this may be broadcast to; without it nothing can be verified.
if (!present(selectedChainId)) {
throw refuse(
"The selected network is unknown, so the signed transaction cannot be checked against it.",
);
}
if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) {
throw refuse(
"The signed transaction is for a different network than the one that is selected.",
);
}
// The approved fee mechanism, named before the type comparison below
// subsumes it: the fee the user agreed to is only meaningful under the
// mechanism it was quoted in, and saying so is more use than "a different
// transaction type".
const approvedEip1559 =
present(approvedTx.maxFeePerGas) ||
present(approvedTx.maxPriorityFeePerGas);
const approvedLegacy = present(approvedTx.gasPrice);
const signedEip1559 = parsed.type === 2;
if (
(approvedEip1559 && !signedEip1559) ||
(approvedLegacy && signedEip1559)
) {
throw refuse(
"The signed transaction does not use the approved fee mechanism.",
);
}
// The type decides which fields are compared, so it is compared first and
// against the approval, not merely checked for membership of the
// allowlist above.
if (!present(approvedTx.type)) {
throw refuse(
"The approved transaction fixes no transaction type, so the signed transaction cannot be checked against what was shown.",
);
}
if (
BigInt(parsed.type) !==
normalizeQuantity(approvedTx.type, "transaction type")
) {
throw refuse(
"The signed transaction does not use the approved transaction type.",
);
}
// Every field this type serializes, compared with the transaction the user
// was shown. Driving the loop off SERIALIZED_FIELDS is what keeps this
// exhaustive: the same table decides what assertNothingUnchecked() rebuilds
// from, so a field that gets signed and is not compared here cannot exist.
for (const key of SERIALIZED_FIELDS[parsed.type]) {
assertFieldMatches(key, parsed, approvedTx);
}
assertWithinCeilings(parsed);
assertNothingUnchecked(parsed);
assertCanonicalBytes(parsed, rawSignedTx);
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 refuse("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 refuse("The signature could not be verified.");
}
if (!sameAddress(recovered, expectedFrom)) {
throw refuse(
"The signature was produced by a different address than the one that was approved.",
);
}
return recovered;
}
// The stage a transaction approval failed at. Which stage it is decides
// whether the approval survives the failure.
const TX_STAGE_SIGN = "sign";
const TX_STAGE_VERIFY = "verify";
const TX_STAGE_BROADCAST = "broadcast";
// Not a failure of this request at all: a second response arrived for an
// approval an attempt already holds. The first attempt is still running and
// may yet succeed, so the one thing the popup must not say is "start again
// from the site".
const TX_STAGE_INFLIGHT = "inflight";
// A transaction refused for a nonce that is already spoken for, either by the
// node's own answer or by this wallet's record of what it has broadcast. It is
// the one broadcast-stage failure that is not ambiguous: the transaction was
// not taken, so the user is told it did not reach the network and to send it
// again, rather than being warned that it might already be out there.
const TX_STAGE_NONCE = "nonce";
function errorText(err) {
if (typeof err === "string" && err !== "") return err;
if (err && (err.shortMessage || err.message)) {
return err.shortMessage || err.message;
}
return "The transaction could not be sent.";
}
// Every string a failure might carry its reason in. ethers reports the node's
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
// nested under `error` or `info.error` with the node's message intact, and the
// classification below has to see that too.
function failureTexts(err) {
if (typeof err === "string") return [err];
if (!err || typeof err !== "object") return [];
const texts = [];
for (const text of [err.shortMessage, err.message, err.reason]) {
if (text) texts.push(String(text));
}
const nested = err.error || (err.info && err.info.error);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
// What the Ethereum clients say when a transaction's nonce is already spoken
// for: either it is below the account's next nonce, or another transaction is
// sitting in the pool at that nonce and this one did not outbid it. Either way
// the node answered, and its answer was that it did not take this transaction.
//
// "already known" is deliberately absent. A node that says it knows the
// transaction has it, so that transaction did reach the network and the
// ambiguous broadcast wording is the correct one for it.
const NONCE_COLLISION_PATTERNS = [
/nonce too low/i,
/nonce has already been used/i,
/invalid nonce/i,
/oldnonce/i,
/replacement transaction underpriced/i,
/replacement fee too low/i,
];
// ethers' own classification of the same two conditions.
const NONCE_COLLISION_CODES = ["NONCE_EXPIRED", "REPLACEMENT_UNDERPRICED"];
// Whether a failed send is a nonce collision.
function isNonceCollision(err) {
if (!err) return false;
if (err.code && NONCE_COLLISION_CODES.includes(err.code)) return true;
return failureTexts(err).some((text) =>
NONCE_COLLISION_PATTERNS.some((pattern) => pattern.test(text)),
);
}
// What both the requesting page and the popup are told about a nonce
// collision. The node's own words ("nonce too low") are a fragment and are
// replaced rather than passed through: they are not a sentence, and they say
// less than the wallet knows.
const NONCE_COLLISION_MESSAGE =
"The transaction was not sent, because its nonce had already been used" +
" by another transaction.";
// What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent
// (resolved to the requesting page as an error and deleted) or left standing
// so the user can try the transaction they already saw again.
//
// - sign: the popup could not produce an artifact, almost always a wrong
// password. Nothing left the extension, so the approval stands.
// - verify: a mismatch is a refusal and spends the approval — an artifact
// that is not the approved transaction must never be retried against that
// approval. Anything else failed before the check ran and is retryable.
// - broadcast: always terminal. A broadcast that throws after the node
// accepted the transaction is routine (a timeout, a dropped response, a
// node answering "already known"), so the wallet cannot tell a transaction
// that never left from one that is already in the mempool. The approval is
// spent and the requesting page has been given its outcome; a second
// attempt against it would report a second outcome for one request.
// - nonce: terminal too, and the one case where the wallet does know the
// transaction never left. The approval carries a nonce that is spent, so
// the artifact signed against it can never be accepted and the user is told
// to send it again from the site.
//
// The stage comes back out because a broadcast failure the node blamed on the
// nonce is reclassified here; the caller reports the stage this returns rather
// than the one it passed in.
function describeTxFailure(stage, err) {
if (
stage === TX_STAGE_NONCE ||
(stage === TX_STAGE_BROADCAST && isNonceCollision(err))
) {
return {
error: NONCE_COLLISION_MESSAGE,
retryable: false,
spendApproval: true,
stage: TX_STAGE_NONCE,
};
}
const error = errorText(err);
const retryable =
stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable, stage };
}
// What the popup shows and does after the background reports a failed signing
// attempt. A retryable failure leaves the approval pending in the background,
// so the button goes back to being usable; a refusal spent the approval, and
// the popup says so rather than offering a button that cannot succeed.
//
// A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the
// wrong instruction. A nonce collision is the exception to that exception —
// the transaction demonstrably did not go out, and saying it might have would
// send the user hunting for a transaction that does not exist.
function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable);
const stage = response && response.stage;
if (!retryable) {
if (stage === TX_STAGE_NONCE) {
message +=
" The transaction did not reach the network." +
" Please send it again from the site.";
} else if (stage === TX_STAGE_BROADCAST) {
message +=
" The transaction may still have reached the network." +
" Check the account before sending it again.";
} else if (stage === TX_STAGE_INFLIGHT) {
message +=
" The first attempt is still running and may still succeed." +
" Wait for it rather than starting again.";
} else {
message +=
" This request can no longer be signed. Please start it" +
" again from the site.";
}
}
return { message, retryable };
}
module.exports = {
verifySignedTx,
verifySignature,
assertNoForbiddenFields,
assertNothingUnchecked,
assertCanonicalBytes,
assertWithinCeilings,
sameAddress,
failureIsRetryable,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
ApprovalMismatchError,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
APPROVED_FIELDS,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
};

View File

@@ -11,14 +11,21 @@ const {
const { ERC20_ABI } = require("./constants");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
const mainnet = Network.from("mainnet");
function getProvider(rpcUrl) {
return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet });
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
// network hint so ethers picks the right chain parameters. When omitted,
// reads the currently selected network from extension state.
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) {
@@ -59,29 +66,40 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const balances = [];
for (const item of items) {
if (item.token?.type !== "ERC-20") continue;
// Case-insensitive: the token type is an explorer's label, not a
// protocol value, and an exact comparison silently drops a real
// holding if one ever writes "erc-20". Which types are admitted
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue;
const tokenAddr = (item.token.address_hash || "").toLowerCase();
const holders = parseInt(item.token.holders_count || "0", 10);
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
// the whole balance list, so it stays strict and admits a token
// only on a reported count — an unreported one is no evidence.
// A legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= 1000;
const hasEnoughHolders =
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
// Skip tokens spoofing a known symbol from a different address
const sym = (item.token.symbol || "").toUpperCase();
const legitAddr = KNOWN_SYMBOLS.get(sym);
if (
legitAddr !== undefined &&
legitAddr !== null &&
tokenAddr !== legitAddr
)
continue;
// Skip tokens spoofing a known symbol from a different address.
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
// refreshBalances and never passes through this loop.
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
balances.push({
address: item.token.address_hash,
@@ -273,6 +291,7 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
}
module.exports = {
fetchTokenBalances,
refreshBalances,
lookupTokenInfo,
getProvider,

262
src/shared/browserApi.js Normal file
View File

@@ -0,0 +1,262 @@
// The one place in this tree that names `browser` or `chrome`.
//
// The two targets do not agree on the namespace, and they disagree about the
// call shape only in which one is native. Chrome MV3 exposes `chrome.*`,
// where tabs, windows and messaging take a trailing callback and report
// failure through the global `chrome.runtime.lastError`. Firefox MV2 exposes
// `browser.*`, where those same methods return promises — but, measured on
// Firefox 153.0.3, it ALSO honours a trailing Chrome-style callback, returns
// no promise when one is given, and populates `browser.runtime.lastError`.
// The callback code that predated this module therefore ran on both, and
// https://git.eeqj.de/sneak/AutistMask/issues/153 was filed on the belief
// that it did not. This module exists for uniformity, not for repair: the
// tree used to resolve the namespace with a ternary at six call sites and
// then mix promise-form storage with callback-form messaging.
//
// The strategy is promises out, everywhere: one namespace, one call shape,
// composing with the `async` handlers in the background. Callers `await`;
// nothing outside this file has to know which browser it is running on.
//
// Two deliberate asymmetries, because they are what the browsers actually do
// rather than what a uniform-looking shim would pretend:
//
// - Storage is called in its PROMISE form on both namespaces.
// `chrome.storage.local.get()` returns a promise on MV3 and the popup
// already depends on that — src/shared/state.js has always awaited it.
// Wrapping it in a callback here would be a change, not a fix.
// - notify() sends without a callback. It is for a message whose answer
// nobody reads; appending a callback would only manufacture a
// lastError/rejection for a receiver that was never expected to reply.
//
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().
// The extension API namespace, preferring `browser.*` where it exists.
//
// Whole-namespace, never per-method: mixing `browser.tabs` with
// `chrome.windows` would also mix promise and callback semantics inside a
// single call path, which is the bug this module exists to remove.
function extensionApi() {
if (typeof browser !== "undefined" && browser) return browser;
if (typeof chrome !== "undefined" && chrome) return chrome;
return null;
}
// True when the resolved namespace is the promise-flavoured one.
//
// It doubles as "this is the Gecko/MV2 build", which is a second question
// with the same answer and one real caller: src/content/index.js has to
// inject the inpage provider itself there, because MV2 has no
// `"world": "MAIN"` for a manifest-declared content script.
function hasBrowserNamespace() {
return typeof browser !== "undefined" && !!browser;
}
function namespaceMember(name) {
const api = extensionApi();
return (api && api[name]) || null;
}
function runtimeApi() {
return namespaceMember("runtime");
}
function tabsApi() {
return namespaceMember("tabs");
}
function windowsApi() {
return namespaceMember("windows");
}
function alarmsApi() {
return namespaceMember("alarms");
}
// The toolbar button. MV3 calls it `action`, MV2 calls it `browserAction`.
function actionApi() {
const api = extensionApi();
if (!api) return null;
return api.action || api.browserAction || null;
}
// `storage.local`, or null in a context that has no storage permission.
//
// Null rather than a throw for the one caller that genuinely degrades:
// src/shared/phishingDomains.js falls back to its vendored blocklist and does
// its own null check. Everything that reads or writes the wallet goes through
// storageGet()/storageSet(), which reject instead — see there.
function storageLocal() {
const storage = namespaceMember("storage");
return (storage && storage.local) || null;
}
// The callback-path error channel. Read only from inside an appended
// callback, i.e. only on the `chrome.*` path, where it is the sole way a
// failure is reported. The background's three explicit lastError checks are
// gone because invoke() turns it into a rejection before any caller sees it.
function lastError() {
const runtime = runtimeApi();
return (runtime && runtime.lastError) || null;
}
// Call `owner[method](...args)` and return a promise for its result.
//
// On the promise namespace the method already returns one. On the callback
// namespace the callback is appended here and lastError becomes a rejection,
// because a caller holding a promise has nowhere to check a global flag.
function invoke(owner, method, ...args) {
if (!owner || typeof owner[method] !== "function") {
return Promise.reject(
new Error(
"extension API " +
method +
"() is not available in this context",
),
);
}
if (hasBrowserNamespace()) {
try {
return Promise.resolve(owner[method](...args));
} catch (e) {
return Promise.reject(e);
}
}
return new Promise((resolve, reject) => {
owner[method](...args, (result) => {
const err = lastError();
if (err) reject(new Error(err.message || String(err)));
else resolve(result);
});
});
}
/**
* Send a message to the extension's own contexts and resolve with the reply.
*
* Rejects when nothing is listening, on both browsers. A caller that does not
* care must say so — see notify().
*
* @param {Object} message
* @returns {Promise<*>} the receiver's response.
*/
function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
*
* @param {Object} message
* @returns {void}
*/
function notify(message) {
const runtime = runtimeApi();
if (!runtime || typeof runtime.sendMessage !== "function") return;
const result = runtime.sendMessage(message);
// MV3 hands back a promise for a one-argument send, and it rejects when
// the background is not listening. Unhandled, that surfaces as an error
// the e2e suites fail the run on.
if (result && typeof result.catch === "function") result.catch(() => {});
}
// These two carry the wallet. A missing `storage.local` has to reject and not
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
);
}
/**
* @param {string|string[]|Object} keys
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.local` is absent.
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
return Promise.resolve(storage.set(items));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
*/
function tabsQuery(queryInfo) {
return invoke(tabsApi(), "query", queryInfo);
}
/**
* Send a message to one tab's content script.
*
* Rejects for a tab that has no receiver, which is most of them. That
* rejection is the promise-shaped replacement for the runtime.lastError
* checks the broadcast helpers used to make, and callers ignore it the same
* way.
*
* @param {number} tabId
* @param {Object} message
* @returns {Promise<*>}
*/
function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
*/
function windowsCreate(createData) {
return invoke(windowsApi(), "create", createData);
}
/**
* @returns {Promise<Object>} the last focused window.
*/
function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @param {number} windowId
* @returns {Promise<void>}
*/
function windowsRemove(windowId) {
return invoke(windowsApi(), "remove", windowId);
}
module.exports = {
actionApi,
alarmsApi,
extensionApi,
hasBrowserNamespace,
notify,
runtimeApi,
sendMessage,
storageGet,
storageLocal,
storageSet,
tabsApi,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
};

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 =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -20,12 +44,29 @@ const ERC20_ABI = [
"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 = {
DEBUG,
BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH,
ERC20_ABI,
BURN_ADDRESSES,
isBurnAddress,
};

41
src/shared/domainHash.js Normal file
View File

@@ -0,0 +1,41 @@
// The one definition of how a domain becomes a blocklist entry.
//
// The vendored phishing blocklist ships digests, not domain names: see
// phishingDomains.js for why, and script/vendor-blocklist for how the artifact
// is produced. Both sides have to agree exactly — a mismatch would silently
// match nothing, which is a blocklist that quietly protects no one — so the
// rule lives here and is required by both rather than written down twice.
//
// sha256 truncated to 64 bits. Truncation is what keeps the artifact small
// enough to bundle (16 hex characters per entry rather than 64), and 64 bits is
// far past what this has to withstand: over ~10^5 entries the chance that any
// hostname a user visits collides with an entry it is not is about 10^-14 per
// lookup, and a deliberate collision buys an attacker a false phishing warning
// on a site they do not control, not a missed one. For scale, Safe Browsing
// distributes 32-bit prefixes and resolves the rest against a server; this is
// 32 bits more, with no server involved.
const { sha256, toUtf8Bytes } = require("ethers");
const HASH_ALGORITHM = "sha256";
const HASH_HEX_CHARS = 16;
/**
* The blocklist entry for a domain: lowercased, hashed, truncated.
*
* @param {string} domain
* @returns {string} HASH_HEX_CHARS lowercase hex characters, no 0x prefix.
*/
function hashDomain(domain) {
// ethers returns "0x" + 64 hex characters.
return sha256(toUtf8Bytes(domain.toLowerCase())).slice(
2,
2 + HASH_HEX_CHARS,
);
}
module.exports = {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
};

View File

@@ -1,6 +1,10 @@
// Cached ENS reverse resolution.
// Resolves addresses to ENS names via ethers provider.lookupAddress(),
// caching results in localStorage with a 12-hour TTL.
//
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead.
const { getProvider } = require("./balances");
const { log } = require("./log");

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

32
src/shared/holders.js Normal file
View File

@@ -0,0 +1,32 @@
// Holder counts, and the one rule that decides whether a count is "low".
//
// The block explorer's holders_count is optional: it is absent on a token it
// has only just indexed, and it goes missing on a degraded or changed API.
// Absent means the count is unknown. It does not mean the token has no
// holders, and collapsing the two hides a token the user really holds as if
// it were spam. Every call site reads the count through here so the
// distinction cannot be lost again in one place while holding in the others.
const LOW_HOLDER_THRESHOLD = 1000;
// Parse an explorer-supplied holders_count into a number, or null when the
// explorer did not report one. Anything unparseable is unknown too: a count
// we cannot read is not a count of zero.
function parseHoldersCount(raw) {
if (raw === null || raw === undefined || raw === "") return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
// True only for a token the explorer reported as having fewer holders than
// the threshold. An unknown count is never low: showing a spam token the
// user can see is unusual costs less than hiding an asset they own.
function isLowHolderCount(holders) {
return holders != null && holders < LOW_HOLDER_THRESHOLD;
}
module.exports = {
LOW_HOLDER_THRESHOLD,
parseHoldersCount,
isLowHolderCount,
};

View File

@@ -1,12 +1,27 @@
// 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 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) {
const threshold = isDebug() ? LEVELS.debug : LEVELS.info;
if (LEVELS[level] >= threshold) {
console[method]("[AutistMask]", ...args);
}
@@ -37,4 +52,4 @@ async function debugFetch(url, opts) {
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 one or more lines are too long

View File

@@ -0,0 +1,158 @@
// Domain-based phishing detection against a blocklist vendored at build time.
//
// The list is produced by script/vendor-blocklist from a hash-pinned upstream
// commit, committed as phishingBlocklist.json, and bundled. There is no runtime
// fetch: the extension asks nobody anything to answer this question, so no third
// party learns which sites a user connects to, and no third party decides what
// this wallet warns about. The cost is staleness — the shipped list is exactly
// as fresh as the last vendoring run that was released — and the refresh path is
// re-running that script and shipping the diff.
//
// The artifact holds digests, not domains: sha256 truncated to 64 bits, one
// entry per 16 hex characters, concatenated in sorted order into a single
// string (see domainHash.js). Three things follow from that shape, and all
// three are the reason for it:
//
// - the extension ships no plaintext list of anyone's domain names, which is
// what makes a blocklist assembled elsewhere shippable here at all.
// - a lookup is a binary search over that string. Nothing is built at module
// load, which matters because the MV3 service worker is torn down when idle
// and re-evaluates this file on every wake.
// - the file is 1.7 MB rather than 8.7 MB.
//
// Nothing here is async: callers answer an approval prompt with the result.
const vendored = require("./phishingBlocklist.json");
const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");
// The artifact is generated, so a shape it does not have is a build fault, not
// a runtime condition. It is checked anyway, and loudly, because every way of
// getting it wrong — a stale format, a truncated file, a different digest —
// produces a blocklist that matches nothing at all while looking perfectly
// healthy. A phishing check that silently answers "no" to everything is the one
// failure this module must not have.
function checkArtifact(a) {
const bad = (why) =>
new Error(
"phishingBlocklist.json " +
why +
". It is generated by script/vendor-blocklist; re-run that " +
"rather than editing it.",
);
if (!a || typeof a !== "object") throw bad("is not an object");
if (a.algorithm !== HASH_ALGORITHM) {
throw bad(
"declares algorithm " +
JSON.stringify(a.algorithm) +
", but this build hashes with " +
HASH_ALGORITHM,
);
}
if (a.hashHexChars !== HASH_HEX_CHARS) {
throw bad(
"declares " +
JSON.stringify(a.hashHexChars) +
" hex characters per entry, but this build produces " +
HASH_HEX_CHARS,
);
}
if (typeof a.hashes !== "string") throw bad("has no hashes string");
if (!Number.isInteger(a.count) || a.count < 1) {
throw bad("declares no usable entry count");
}
if (a.hashes.length !== a.count * HASH_HEX_CHARS) {
throw bad(
"holds " +
a.hashes.length +
" hex characters, which is not the " +
a.count * HASH_HEX_CHARS +
" its count of " +
a.count +
" entries requires",
);
}
}
checkArtifact(vendored);
const HASHES = vendored.hashes;
const COUNT = vendored.count;
/**
* Is this digest one of the vendored entries?
*
* Binary search over fixed-width records. The digests are lowercase hex of one
* width, so lexicographic order is numeric order and the artifact is written
* sorted; tests assert that ordering against the committed file, because an
* unsorted artifact would fail lookups silently rather than loudly.
*
* @param {string} hash
* @returns {boolean}
*/
function hashListed(hash) {
let lo = 0;
let hi = COUNT - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const at = HASHES.slice(
mid * HASH_HEX_CHARS,
(mid + 1) * HASH_HEX_CHARS,
);
if (at === hash) return true;
if (at < hash) lo = mid + 1;
else hi = mid - 1;
}
return false;
}
/**
* 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.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
*/
function isPhishingDomain(hostname) {
if (!hostname) return false;
for (const variant of hostnameVariants(hostname)) {
if (hashListed(hashDomain(variant))) return true;
}
return false;
}
/**
* Return the blocklist size for diagnostics.
*
* @returns {number}
*/
function getBlocklistSize() {
return COUNT;
}
module.exports = {
isPhishingDomain,
getBlocklistSize,
hostnameVariants,
// Exposed for testing only: the ends of the search range are where an
// off-by-one hides, and reaching them through isPhishingDomain() would mean
// knowing which domain hashes to the first or last entry.
_hashListed: hashListed,
};

View File

@@ -8,18 +8,37 @@ const prices = {};
let lastFetchedAt = 0;
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();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
try {
const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched);
lastFetchedAt = now;
} catch (e) {
} catch {
// prices stay stale on error
}
}
// 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) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
return prices[symbol] || null;
}
@@ -36,44 +55,87 @@ function formatUsd(amount) {
);
}
function getAddressValueUsd(addr) {
if (!prices.ETH) return null;
let total = 0;
const ethBal = parseFloat(addr.balance || "0");
total += ethBal * prices.ETH;
// What an address is worth, as { usd, partial }.
//
// Prices are fetched for the top 25 tokens only, so an address can hold real
// assets this code has no price for. Adding up the priced ones and calling the
// result the total states a number the holdings do not support: an address
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
// their address is worth nothing when it may hold a great deal. Worth zero and
// worth an unknown amount are separate facts and get separate fields, the same
// way an absent holders_count is not a count of zero.
//
// usd: the value of the holdings a price is known for, or null when
// nothing is knowable at all — testnet, or before the first fetch.
// partial: the address also holds a token with no price, so usd is a floor
// and not the total.
//
// Render it through formatAddressTotal() rather than reading usd alone.
function getAddressValue(addr) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return { usd: null, partial: false };
if (!prices.ETH) return { usd: null, partial: false };
let usd = parseFloat(addr.balance || "0") * prices.ETH;
let partial = false;
for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0");
if (tokenBal > 0 && prices[token.symbol]) {
total += tokenBal * prices[token.symbol];
// A balance of zero is not a holding: it can neither add to the total
// nor make it incomplete.
if (!(tokenBal > 0)) continue;
if (prices[token.symbol]) {
usd += tokenBal * prices[token.symbol];
} else {
partial = true;
}
}
return total;
return { usd, partial };
}
function getWalletValueUsd(wallet) {
if (!prices.ETH) return null;
let total = 0;
for (const addr of wallet.addresses) {
total += getAddressValueUsd(addr);
}
return total;
// The same pair for a whole wallet, and for every wallet at once. One
// unpriced holding anywhere makes the sum a floor, so partial carries up.
function getWalletValue(wallet) {
return sumValues(wallet.addresses.map(getAddressValue));
}
function getTotalValueUsd(wallets) {
if (!prices.ETH) return null;
let total = 0;
for (const wallet of wallets) {
total += getWalletValueUsd(wallet);
function getTotalValue(wallets) {
return sumValues(wallets.map(getWalletValue));
}
function sumValues(values) {
let usd = null;
let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
}
return total;
return { usd, partial };
}
// The one rendering of an address total, so no screen says it differently.
//
// A partial total is shown and named as partial: the figure is the ETH and
// priced tokens the user does hold, which is worth having, and suppressing it
// would throw away a number that is correct as far as it goes. What is never
// shown is a figure covering no holdings at all — the $0.00 sum of an empty
// set beside a list of tokens is the bug this replaces.
function formatAddressTotal(value) {
if (!value || value.usd === null) return "";
if (!value.partial) return "Total: " + formatUsd(value.usd);
if (value.usd > 0) {
return "Total: " + formatUsd(value.usd) + " plus unpriced tokens";
}
return "Total: unpriced tokens only";
}
module.exports = {
prices,
refreshPrices,
clearPrices,
getPrice,
formatUsd,
getAddressValueUsd,
getWalletValueUsd,
getTotalValueUsd,
formatAddressTotal,
getAddressValue,
getWalletValue,
getTotalValue,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,17 @@
// State management and extension storage persistence.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const { storageGet, storageSet } = require("./browserApi");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
lastBalanceRefresh: 0,
@@ -19,12 +20,16 @@ const DEFAULT_STATE = {
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
const state = {
@@ -34,13 +39,53 @@ const state = {
selectedAddress: null,
selectedToken: null,
viewData: {},
viewStack: [],
};
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
}
async function saveState() {
const persisted = {
hasWallet: state.hasWallet,
wallets: state.wallets,
trackedTokens: state.trackedTokens,
networkId: state.networkId,
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
lastBalanceRefresh: state.lastBalanceRefresh,
@@ -49,28 +94,37 @@ async function saveState() {
deniedSites: state.deniedSites,
rememberSiteChoice: state.rememberSiteChoice,
showZeroBalanceTokens: state.showZeroBalanceTokens,
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei,
utcTimestamps: state.utcTimestamps,
fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache,
theme: state.theme,
debugMode: state.debugMode,
currentView: state.currentView,
selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress,
selectedToken: state.selectedToken,
viewData: state.viewData,
viewStack: state.viewStack,
};
await storageApi.set({ autistmask: persisted });
await storageSet({ autistmask: persisted });
}
async function loadState() {
const result = await storageApi.get("autistmask");
const result = await storageGet("autistmask");
if (result.autistmask) {
const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
@@ -92,6 +146,12 @@ async function loadState() {
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it.
// It is a safety filter, so absent must load as on, not as undefined.
state.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
state.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
@@ -108,8 +168,13 @@ async function loadState() {
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null;
state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
@@ -117,6 +182,7 @@ async function loadState() {
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {};
state.viewStack = restorableStack(saved.viewStack, state.currentView);
}
}
@@ -127,4 +193,10 @@ function currentAddress() {
return state.wallets[state.selectedWallet].addresses[state.selectedAddress];
}
module.exports = { state, saveState, loadState, currentAddress };
module.exports = {
state,
saveState,
loadState,
currentAddress,
currentNetwork,
};

108
src/shared/symbolSpoof.js Normal file
View File

@@ -0,0 +1,108 @@
// The known-symbol spoof rule, in one place.
//
// A token that borrows a known symbol from a contract that is not the one
// that symbol belongs to is a spoof, and the wallet hides it. Three surfaces
// ask that question — the transaction history, the Send token selector and
// the balance list — and they must answer it identically: a token the history
// calls fake while the balance list lists it as a holding is worse than
// either verdict alone, because the balance list is where the user forms
// their belief about what they own (issue #235).
//
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// that may bear it, or to null. Null means the symbol belongs to the native
// asset, which has no contract at all, so no contract may bear it and every
// one that does is a spoof. "ETH" is the only such entry today; the rule is
// written so that a second one needs no change here or at any call site.
//
// The value is a set because a ticker is not unique: seven symbols in the
// bundled list belong to two real contracts each, and answering with one of
// them hid the other one's holders' money (issue #276). Membership, not
// equality, is therefore the question — but it is the same question, asked of
// a table that can now state the truth. Every address in a set is one the
// wallet ships as a real token; a contract outside the set is still a spoof.
//
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
// returns — so the lookup is done on a normalized form (issue #260): the
// question is whether the symbol reaches the user's eye as a known one,
// since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
// Fold a symbol onto what a user actually sees, and no further:
//
// NFKC collapses compatibility variants that render as the ASCII
// letters they imitate — fullwidth ETH, styled mathematical
// letters — and maps the non-ASCII spaces onto U+0020.
// strip drops what paints nothing: \p{Cf} plus
// \p{Default_Ignorable_Code_Point} plus U+007F. That covers
// the format characters (zero-width space, joiner and
// non-joiner, word joiner, soft hyphen, byte-order mark, bidi
// marks and overrides), the variation selectors, the Hangul
// fillers, and DELETE. Removed everywhere, not merely at the
// ends.
// trim removes surrounding whitespace, which HTML collapses:
// `" ETH "` is painted next to the user's real ETH as `ETH`.
// toUpperCase makes the comparison case-insensitive, as before.
//
// The rule is "strip what paints nothing". The Unicode classes are how
// that is spelled, not what it means, which is why U+007F is named on its
// own: it is a control rather than a default-ignorable character, so no
// class here reaches it, yet it paints nothing all the same. Measured in
// the repo's pinned e2e Chromium (16px sans-serif, plain `ETH` = 32.00px,
// so an invisible prefix leaves 32.00px):
//
// U+007F, U+3164, U+115F, U+FE0F, U+FE00 32.00px — invisible
// U+FFA0 40.00px — a box
// U+1160 48.00px — a box
// U+0001, U+0085, U+0090 48.00px — a box
//
// U+1160 and U+FFA0 are `Default_Ignorable_Code_Point` members that font
// fallback nonetheless draws, and they are stripped anyway: erring toward
// hiding a token that does not look like `ETH` is the harmless direction of
// the two. The other controls are left alone for the same reason read the
// other way — a symbol carrying a visible box does not reach the eye as
// `ETH`, so filtering it would hide a token the user could not have
// confused with the native asset.
//
// Deliberately not folded, and asserted as open in tests/symbolSpoof.test.js:
// interior whitespace (`E T H` renders as `E T H`, so folding it would filter
// a token nobody could confuse with the native asset), confusables that are
// distinct letters rather than compatibility variants (Cyrillic capital Ie,
// U+0415; Greek capital Epsilon, U+0395), bidi reordering, which needs the
// bidi algorithm rather than a character filter, and the visible controls.
//
// This decides only how the question is asked. Nothing here changes what a
// surface displays; a token still shows the symbol it reports.
function normalizeSymbol(symbol) {
return String(symbol || "")
.normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
.trim()
.toUpperCase();
}
// True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol.
//
// An empty contract address is the native asset, which is never a spoof:
// this is what keeps the user's real ETH out of the rule, and it holds for
// any symbol that becomes null-mapped later, not just for ETH.
function isSpoofedSymbol(symbol, contractAddress) {
const contract = normalizeAddress(contractAddress);
if (!contract) return false;
const sym = normalizeSymbol(symbol);
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
return !legit.has(contract);
}
module.exports = {
isSpoofedSymbol,
};

View File

@@ -3607,14 +3607,33 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
}
// Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
// Used for spoofed-symbol detection. "ETH" maps to null (native token).
// Build a map of symbol (uppercased) -> the set of contract addresses
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol.
//
// The value is a set and not a single address because tickers are not unique
// and the list above proves it: seven of these 512 tokens share a symbol with
// another entry — FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC — at two
// different real contracts each, all of them from the same source fetch. A
// one-address-per-symbol table can only answer that by picking a winner, and
// the loser is then a token in our own bundled list that the spoof filter
// hides from the balance list, the history and the send selector at its own
// address, so the user cannot spend it (issue #276). Naming every address
// that bears the symbol is the only shape that says what is true; it does not
// loosen the rule, because a contract outside the set is still a spoof.
const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null);
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) {
KNOWN_SYMBOLS.set(upper, t.address.toLowerCase());
KNOWN_SYMBOLS.set(upper, new Set());
}
const addresses = KNOWN_SYMBOLS.get(upper);
// A null entry is the native asset and stays null: an ERC-20 that reports
// the native symbol does not thereby become entitled to it.
if (addresses !== null) {
addresses.add(t.address.toLowerCase());
}
}

View File

@@ -8,7 +8,17 @@
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
// this file goes through this helper, so an address arriving in checksummed
// or upper-case form can never be read as a different address.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function formatTxValue(val) {
const parts = val.split(".");
@@ -30,10 +40,10 @@ function parseTx(tx, addrLower) {
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
let rawUnit = "wei";
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) {
symbol = token.symbol;
}
@@ -87,7 +97,8 @@ function parseTokenTransfer(tt, addrLower) {
const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0";
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
const direction =
normalizeAddress(from) === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
return {
hash: tt.transaction_hash,
@@ -104,18 +115,98 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false,
contractAddress: (
tt.token?.address_hash ||
tt.token?.address ||
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10),
contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "",
),
// null when the explorer reported no count: unknown, not zero. The
// low-holder filter declines to judge a null, so a legitimate token
// is not hidden because a field went missing upstream.
holders: parseHoldersCount(tt.token?.holders_count),
};
}
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase();
const addrLower = normalizeAddress(address);
const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -145,90 +236,62 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txsByHash = new Map();
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length);
return result;
}
// Check if a token transfer is spoofing a known symbol.
// Returns true if the symbol matches a known token but the contract
// address doesn't match the legitimate one.
function isSpoofedSymbol(tx) {
if (!tx.contractAddress) return false;
const symbol = (tx.symbol || "").toUpperCase();
if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake
return tx.contractAddress !== legit;
}
// Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) {
const fraudSet = new Set(
(filters.fraudContracts || []).map((a) => a.toLowerCase()),
(filters.fraudContracts || []).map(normalizeAddress),
);
// The dust threshold defaults only when it is unset (nullish): a
// threshold of 0 is a real value meaning "hide nothing", since no
// transaction has a value below 0 gwei. It is therefore equivalent to
// clearing the hide-dust checkbox, and the two controls cannot override
// each other in either direction.
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
const newFraud = [];
const filtered = [];
// Fail-safe, unlike the three flags below: this one is off only when the
// caller says so explicitly, so a caller that omits the key keeps the
// check rather than silently losing it. The setting also governs the
// blocklist learning below, which exists only to serve this check —
// leaving learning on while the check is off would re-hide the very rows
// the user asked to see, through the fraud-contract rule.
const hideSpoofed = filters.hideSpoofedSymbols !== false;
for (const tx of txs) {
// Always filter spoofed known symbols and record the fraud contract
if (isSpoofedSymbol(tx)) {
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
fraudSet.add(tx.contractAddress);
newFraud.push(tx.contractAddress);
const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
}
continue;
}
// Filter fraud contracts if setting is on
if (
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
continue;
}
// Filter low-holder tokens (<1000) if setting is on
// Filter low-holder tokens (<1000) if setting is on. A token whose
// holder count the explorer did not report is kept: only a reported
// count below the threshold is "low".
if (
filters.hideLowHolderTokens &&
tx.contractAddress &&
tx.holders !== null &&
tx.holders < 1000
isLowHolderCount(tx.holders)
) {
continue;
}
@@ -240,7 +303,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions &&
!tx.isContractCall &&
tx.valueGwei !== null &&
tx.valueGwei < (filters.dustThresholdGwei || 100000)
tx.valueGwei < dustThresholdGwei
) {
continue;
}
@@ -251,40 +314,8 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud };
}
/**
* Check whether an address has any on-chain transaction history.
* Returns true if the address has zero normal transactions AND zero
* token transfers on the configured Blockscout instance.
* Returns false on network errors (fail-open: don't block sends).
*/
async function hasZeroTransactionHistory(address, blockscoutUrl) {
try {
const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/transactions?limit=1",
);
if (!resp.ok) return false;
const json = await resp.json();
if ((json.items || []).length > 0) return false;
// Also check token transfers — an address may have only received
// ERC-20 tokens without any native ETH transactions.
const ttResp = await debugFetch(
blockscoutUrl +
"/addresses/" +
address +
"/token-transfers?type=ERC-20&limit=1",
);
if (!ttResp.ok) return false;
const ttJson = await ttResp.json();
return (ttJson.items || []).length === 0;
} catch (e) {
log.errorf("hasZeroTransactionHistory check failed:", e.message);
return false;
}
}
module.exports = {
fetchRecentTransactions,
filterTransactions,
hasZeroTransactionHistory,
mergeTransactions,
};

171
src/shared/txValidation.js Normal file
View File

@@ -0,0 +1,171 @@
// Balance arithmetic for the transaction confirmation screen.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
// instead of through the confirmation view. The caller maps the returned
// codes to the reserved message elements on the screen.
//
// Human decimal strings ("1.25") are scaled to 18 decimals for comparison.
// That scale is independent of a token's own decimals: both the amount and
// the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact.
const { parseUnits } = require("ethers");
const SCALE_DECIMALS = 18;
// Whether the asynchronous fee estimate has arrived yet.
const FEE_PENDING = "pending";
const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
// ETH: the amount alone already exceeds the ETH balance.
INSUFFICIENT_ETH: "insufficient-eth",
// ETH: the amount fits, the amount plus the network fee does not.
INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee",
// ERC-20: the token balance covers the transfer, the ETH balance does
// not cover the network fee it costs.
INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee",
// The fee estimate has not arrived yet.
FEE_PENDING: "fee-pending",
// The fee estimate failed. Unknown is never treated as zero.
FEE_UNAVAILABLE: "fee-unavailable",
};
// The fee that must be reserved for a transaction, in wei: the amount the
// node will require, not the amount the transaction is expected to cost.
//
// A send that pins no fee fields is populated by ethers as a type-2
// (EIP-1559) transaction, and a node validates that against
// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as
// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice`
// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and
// lets through a transaction the node then rejects with "insufficient funds
// for gas * price + value". gasPrice is the fallback only for a network that
// offers no type-2 pricing at all.
//
// Returns null when no usable price is available, which the caller must treat
// as a failed estimate rather than as a free transaction.
function feeReserveWei(gasLimit, feeData) {
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
const price = feeData?.maxFeePerGas ?? feeData?.gasPrice;
if (typeof price !== "bigint" || price < 0n) return null;
return gasLimit * price;
}
// What the transaction is expected to actually cost, in wei — not what must
// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas
// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that,
// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network
// with no type-2 pricing the two are the same number.
//
// Display only: nothing gates on this. Returns null on the same unusable
// inputs as feeReserveWei().
function feeEstimateWei(gasLimit, feeData) {
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
const price = feeData?.gasPrice ?? feeData?.maxFeePerGas;
if (typeof price !== "bigint" || price < 0n) return null;
return gasLimit * price;
}
// Scale a human decimal string to 18-decimal fixed point. Returns null when
// the value is not a decimal number or carries more precision than the scale
// can hold, which the caller must treat as unusable rather than as zero.
function toFixedPoint(value) {
if (typeof value !== "string" && typeof value !== "number") return null;
const text = String(value).trim();
if (text === "") return null;
try {
return parseUnits(text, SCALE_DECIMALS);
} catch {
return null;
}
}
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
// is treated as FEE_UNAVAILABLE.
// feeWei — the fee reserve in wei from feeReserveWei(), as a
// non-negative bigint, when FEE_KNOWN. Any other value makes
// the fee unavailable rather than zero.
//
// Returns { canSend, codes }. Every code blocks sending: canSend is true
// only when nothing was found.
function validateTransfer({
isErc20 = false,
amount,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
feeWei = null,
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
// Fail closed. Anything that is not a usable fee under a recognised
// status — a malformed feeWei, or a status this module does not know —
// is an unavailable estimate, never a fee of zero. Every such input errs
// in the direction that lets money out, so none of them is trusted.
const known =
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n;
let status = feeStatus;
if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE;
if (status !== FEE_KNOWN && status !== FEE_PENDING) {
status = FEE_UNAVAILABLE;
}
const feeFp = known ? feeWei : null;
if (isErc20) {
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
if (feeFp !== null && feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
}
} else if (amountFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH);
} else if (feeFp !== null && amountFp + feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE);
}
// An unknown fee is never assumed to be zero: sending stays blocked
// until the estimate arrives, and stays blocked if it never does.
if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING);
if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
return { canSend: codes.length === 0, codes };
}
module.exports = {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
toFixedPoint,
validateTransfer,
};

View File

@@ -102,6 +102,11 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
@@ -359,9 +364,12 @@ function decode(data, toAddress) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
if (!minOutput) minOutput = s.amountOutMin;
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
}
}
@@ -369,9 +377,9 @@ function decode(data, toAddress) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
if (!minOutput) minOutput = s.amountOutMin;
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
}
}
@@ -388,12 +396,11 @@ function decode(data, toAddress) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
if (!minOutput && v4.amountOutMin)
minOutput = v4.amountOutMin;
// Always update output: last swap step wins
if (v4.tokenOut) outputToken = v4.tokenOut;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
}
}

View File

@@ -1,14 +1,80 @@
// Vault: password-based encryption of secrets using libsodium.
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
// All crypto operations are delegated to libsodium — no raw primitives.
//
// Backend: WebAssembly, deliberately (#182).
//
// libsodium ships one file containing both a WebAssembly build and a
// wasm2js ("asm.js") translation of it. It tries WASM first and, if
// instantiation throws, silently swaps in the translation. An extension
// CSP of plain script-src 'self' refuses WASM, so every popup load used
// to take that fallback — announced by nothing but an uncaught
// CompileError in the console.
//
// Measured here, same Argon2id parameters (OPSLIMIT_INTERACTIVE,
// MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22 on this machine:
// WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is
// identical either way — it is set by the ops/mem parameters, not by wall
// time — so the fallback bought no security, it only made every password
// operation take three and a half seconds, and the wallet asks for the
// password on every signature.
//
// So both manifests declare 'wasm-unsafe-eval' for extension pages. That
// keyword permits compiling WebAssembly and nothing else: not eval() of
// strings, not inline script, not remote script. Reaching it requires
// already executing script in the extension page, which is total
// compromise on its own. 'unsafe-eval' would be a different matter and is
// not granted. tests/manifest.test.js pins both policies to exactly
// "'self' 'wasm-unsafe-eval'" so neither the grant nor the surrounding
// strictness can drift unnoticed.
//
// The fallback still exists, and a wallet that refuses to decrypt is
// worse than a slow one, so it is not disabled — it is made loud:
// cryptoBackend() reports which backend this realm can run, ensureReady()
// logs an error if it is not WASM, tests/vaultBackend.test.js asserts the
// unit tests exercise the WASM backend, and the end-to-end suite asserts
// it in the real popup under the real manifest.
const sodium = require("libsodium-wrappers-sumo");
const { log } = require("./log");
// An empty WebAssembly module: the 8-byte magic number and version header,
// no sections. Compiling it asks the cheapest possible form of the only
// question that matters here — may this realm compile WebAssembly at all —
// which is exactly what a CSP without 'wasm-unsafe-eval' refuses, and
// exactly what decides which backend libsodium ends up on.
const EMPTY_WASM_MODULE = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
]);
// "wasm" or "asmjs": whether this realm may compile WebAssembly, which is
// what decides libsodium's backend when the CSP is the reason it cannot —
// the case this codebase guards. It probes the realm, not libsodium, so a
// fallback taken for some other reason (allocation failure, corrupt module)
// would not be caught here; tests/vaultBackend.test.js checks libsodium's
// own marker directly.
async function cryptoBackend() {
try {
await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm";
} catch {
return "asmjs";
}
}
let ready = false;
async function ensureReady() {
if (!ready) {
await sodium.ready;
if ((await cryptoBackend()) !== "wasm") {
log.errorf(
"libsodium is running on the wasm2js fallback: this realm " +
"refuses to compile WebAssembly, so every password " +
"derivation costs roughly 20x what it should. See the " +
"backend note in src/shared/vault.js.",
);
}
ready = true;
}
}
@@ -59,4 +125,4 @@ async function decryptWithPassword(encrypted, password) {
return sodium.to_string(plaintext);
}
module.exports = { encryptWithPassword, decryptWithPassword };
module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };

View File

@@ -5,6 +5,10 @@ const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
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;
const m = Mnemonic.fromEntropy(
globalThis.crypto.getRandomValues(new Uint8Array(16)),
@@ -12,8 +16,60 @@ function generateMnemonic() {
return m.phrase;
}
// Every extended key (xprv or xpub) entering the app goes through this.
//
// ethers' HDNodeWallet.fromExtendedKey does NOT verify the base58 checksum
// when the decoded payload is the usual 82 bytes, which is exactly the case
// the checksum exists to catch: a key with a one-character typo parses into a
// *different* wallet instead of being rejected. Re-encoding the parsed node
// reproduces a well-formed key byte for byte, checksum included, so comparing
// the round trip against the input rejects any altered character. Measured by
// the sweep in tests/wallet.test.js over every single-character substitution
// of the BIP-32 vector 1 master key: 199 parse without the round-trip
// comparison, 0 with it.
//
// Returns the parsed node, or null if the key is not a well-formed extended
// key. Callers turn null into a user-facing error; none of them may fall back
// to fromExtendedKey directly.
function parseExtendedKey(key) {
if (typeof key !== "string") return null;
try {
const node = HDNodeWallet.fromExtendedKey(key);
return node.extendedKey === key ? node : null;
} catch {
return null;
}
}
// A master key is at depth 0. Only from there is BIP44_ETH_PATH the absolute
// path it names; deriving it under an account-level or child key yields
// addresses that correspond to nothing the user holds.
const MASTER_DEPTH = 0;
// Parse an extended private key that the BIP-44 Ethereum account path can be
// derived from, or throw. Both callers derive BIP44_ETH_PATH from the result.
function masterXprvOrThrow(key) {
const node = parseExtendedKey(key);
if (!node) {
throw new Error("Not a valid extended private key (xprv).");
}
if (!node.privateKey) {
throw new Error("Not an extended private key (xprv).");
}
if (node.depth !== MASTER_DEPTH) {
throw new Error(
"Not a master extended private key (xprv): an account-level or " +
"child key cannot be imported.",
);
}
return node;
}
function deriveAddressFromXpub(xpub, index) {
const node = HDNodeWallet.fromExtendedKey(xpub);
const node = parseExtendedKey(xpub);
if (!node) {
throw new Error("Not a valid extended key.");
}
return node.deriveChild(index).address;
}
@@ -24,6 +80,31 @@ function hdWalletFromMnemonic(mnemonic) {
return { xpub, firstAddress };
}
function hdWalletFromXprv(xprv) {
// BIP44_ETH_PATH is absolute ("m/..."), which ethers will only derive from
// a depth-0 node. The relative form this used to derive would have been
// applied *beneath* an account-level key instead of being refused.
const node = masterXprvOrThrow(xprv).derivePath(BIP44_ETH_PATH);
const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress };
}
// Well-formed extended private key. Says nothing about depth: the import view
// reports a non-master key separately, since "check it for a typo" is the
// wrong advice for a key the user copied correctly.
function isValidXprv(key) {
const node = parseExtendedKey(key);
return !!(node && node.privateKey);
}
// Whether an extended key is a master key, i.e. the one BIP44_ETH_PATH can be
// derived from. False for anything parseExtendedKey rejects.
function isMasterExtendedKey(key) {
const node = parseExtendedKey(key);
return !!node && node.depth === MASTER_DEPTH;
}
function addressFromPrivateKey(key) {
const w = new Wallet(key);
return w.address;
@@ -38,6 +119,26 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
);
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
// Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js
// catches it at list-render time; this is the backstop behind that.
const node = parseExtendedKey(decryptedSecret);
if (!node || !node.privateKey) {
throw new Error(
"This wallet's stored key is not a valid extended private " +
"key, so it cannot sign.",
);
}
if (node.depth !== MASTER_DEPTH) {
throw new Error(
"This wallet was imported from an extended private key that " +
"is not a master key, so it cannot sign.",
);
}
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
}
return new Wallet(decryptedSecret);
}
@@ -45,11 +146,25 @@ function isValidMnemonic(mnemonic) {
return Mnemonic.isValidMnemonic(mnemonic);
}
// Only an HD wallet has a recovery phrase. A "key" wallet holds a bare
// private key and an "xprv" wallet an extended private key; neither can be
// turned back into words, so neither may ever be offered the phrase display.
// Written as an allowlist on purpose: a wallet type added later is excluded
// until someone decides otherwise.
function walletHasRecoveryPhrase(walletData) {
return !!walletData && walletData.type === "hd";
}
module.exports = {
generateMnemonic,
parseExtendedKey,
deriveAddressFromXpub,
hdWalletFromMnemonic,
hdWalletFromXprv,
isValidXprv,
isMasterExtendedKey,
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,
walletHasRecoveryPhrase,
};

View File

@@ -0,0 +1,86 @@
// Wallets already in stored state whose key cannot be used, and the copy that
// explains them.
//
// Refusing a non-master extended private key at import time does nothing for a
// wallet imported before that refusal existed. Such a wallet is detected here,
// at wallet-list render time, so the user meets the explanation on the list
// screen rather than an exception on the send screen. Nothing here modifies or
// removes a wallet: the record is the user's data.
const { parseExtendedKey } = require("./wallet");
const NON_MASTER_XPRV = "non-master-xprv";
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
// the key that was imported: the current import path derives the absolute
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
// four levels as a relative path beneath whatever depth it was given. A master
// import therefore stores a depth-4 xpub and a depth-d import stores depth
// d + 4, which makes the stored xpub an exact read on the imported key's
// depth — and it is readable without the password, unlike the key itself.
const BIP44_ETH_XPUB_DEPTH = 4;
const DEFECTS = {
[NON_MASTER_XPRV]: {
id: NON_MASTER_XPRV,
heading: "This wallet's addresses were derived incorrectly.",
paragraphs: [
"This wallet was imported from an extended private key that is " +
"not a master key. An earlier version applied the Ethereum " +
"derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " +
"produces under the standard path.",
"Signing and sending are disabled for this wallet. The addresses " +
"do descend from the extended private key you imported, so " +
"anything they hold is still reachable by software that " +
"repeats the same non-standard derivation. Check them in a " +
"block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " +
"changed or removed, and this wallet stays until you delete " +
"it yourself.",
],
// One sentence for the places that have room for one: the flash on a
// blocked Send, the inline error on the approval screens.
shortMessage:
"This wallet cannot sign, because it was imported from an " +
"extended private key that is not a master key. The wallet list " +
"explains what happened.",
},
};
// The defect record for a wallet, or null if there is nothing wrong with it
// that this module can see. Read-only.
//
// A wallet whose xpub will not parse gets null rather than a defect: there is
// no basis in that case to tell the user their key was not a master key, and a
// wrong explanation is worse than none.
function walletDefect(walletData) {
if (!walletData || walletData.type !== "xprv") return null;
const node = parseExtendedKey(walletData.xpub);
if (!node) return null;
if (node.depth === BIP44_ETH_XPUB_DEPTH) return null;
return DEFECTS[NON_MASTER_XPRV];
}
// The notice block for the wallet list, or "" for a wallet with no defect.
// The copy is fixed text from this module, so it needs no escaping.
function walletDefectHtml(walletData) {
const defect = walletDefect(walletData);
if (!defect) return "";
let html =
'<div class="border border-red-500 border-dashed p-2 my-1 text-xs text-red-500">';
html += `<div class="font-bold mb-1">${defect.heading}</div>`;
for (const p of defect.paragraphs) {
html += `<p class="mb-1">${p}</p>`;
}
html += "</div>";
return html;
}
module.exports = {
NON_MASTER_XPRV,
walletDefect,
walletDefectHtml,
};

157
src/shared/walletDelete.js Normal file
View File

@@ -0,0 +1,157 @@
// Wallet and address deletion state transitions, kept out of the views so the
// selection and broadcast rules are testable without a DOM.
const { notify } = require("./browserApi");
// Two records of the same address can be stored in different cases, so
// address equality is never a literal string comparison.
function sameAddress(a, b) {
if (a === null || a === undefined || b === null || b === undefined) {
return false;
}
return String(a).toLowerCase() === String(b).toLowerCase();
}
// Forget every site permission held against the given addresses.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
}
// 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 = addresses.some((a) =>
sameAddress(a, previousActive),
);
state.wallets.splice(walletIdx, 1);
dropSitePermissions(state, addresses);
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 };
}
// Whether a wallet may be offered a per-address remove control, and the same
// gate the removal itself is held behind.
//
// Only a wallet that derives its addresses from an extended key can hold more
// than one, so only those get the control — a key wallet has exactly one
// address and no "+" button either. The last address of any wallet is never
// removable: a wallet with no addresses is what delete-wallet is for.
function canRemoveAddress(wallet) {
if (!wallet) return false;
if (wallet.type !== "hd" && wallet.type !== "xprv") return false;
return (wallet.addresses || []).length > 1;
}
// Remove address `addrIdx` of wallet `walletIdx` and repair the derived state.
//
// Nothing is destroyed here. The address stays derivable from the wallet's own
// key material and any funds at it are untouched; this only stops the wallet
// tracking it. `nextIndex` is deliberately left alone — it is a derivation
// high-water mark, so "+" derives a fresh index rather than handing back the
// address just removed, and the gap it leaves is within what
// `scanForAddresses()` re-discovers on a later import.
//
// The rules mirror removeWalletFromState() one level down:
// - The call is refused unless canRemoveAddress() allows it, so the last
// address of a wallet always survives.
// - Site permissions are dropped for the removed address.
// - `selectedAddress` follows the splice, but only within the wallet that
// lost the address: it is decremented when an earlier address was
// removed, and falls back to that wallet's first address when the
// selection itself was removed. `selectedWallet` never moves, because the
// wallet list does not.
// - `activeAddress` moves only when it was the removed address, and then to
// the wallet's first remaining address.
//
// Returns whether the address was removed and whether `activeAddress`
// changed, so the caller can broadcast it.
function removeAddressFromState(state, walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const refused = { removed: false, activeAddressChanged: false };
if (!canRemoveAddress(wallet)) return refused;
if (!wallet.addresses[addrIdx]) return refused;
const address = wallet.addresses[addrIdx].address;
const previousActive = state.activeAddress;
const activeWasRemoved = sameAddress(address, previousActive);
wallet.addresses.splice(addrIdx, 1);
dropSitePermissions(state, [address]);
if (state.selectedWallet === walletIdx) {
if (state.selectedAddress === addrIdx) {
state.selectedAddress = 0;
} else if (
typeof state.selectedAddress === "number" &&
state.selectedAddress > addrIdx
) {
state.selectedAddress -= 1;
}
}
if (activeWasRemoved) {
state.activeAddress = wallet.addresses[0].address;
}
return {
removed: true,
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() {
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
module.exports = {
canRemoveAddress,
removeAddressFromState,
removeWalletFromState,
broadcastActiveChanged,
};

238
tests/addressValue.test.js Normal file
View File

@@ -0,0 +1,238 @@
// The USD total of an address that holds something this build cannot price
// (issue #261).
//
// Prices exist for the top 25 tokens only, so an address can hold real assets
// with no price attached. Summing what is priced and printing the result as
// the total says "$0.00" for an address holding nothing but unpriced tokens —
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
// direction that matters. The two are separate facts here, the same way an
// absent holders_count is not a count of zero.
//
// The value and its rendering are asserted directly, and then through the two
// call sites that return their markup as a string: the wallet list on Home and
// the balance warning on the address-removal confirmation. AddressDetail and
// the Home summary line render into the DOM and are covered by tests/e2e.
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const {
prices,
clearPrices,
getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal,
} = require("../src/shared/prices");
const { state } = require("../src/shared/state");
const { walletListHtml } = require("../src/popup/views/home");
const { balanceWarningHtml } = require("../src/popup/views/deleteAddress");
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const NOVEL = "0x1111111111111111111111111111111111111111";
// No ETH, and a token no price is known for. The case the user is told is
// worth $0.00 today.
const UNPRICED_ONLY = {
address: "0x" + "a".repeat(40),
balance: "0",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "5000.0" }],
};
// Nothing at all: the address really is worth zero.
const EMPTY = {
address: "0x" + "b".repeat(40),
balance: "0",
tokenBalances: [],
};
// Every holding priced.
const FULLY_PRICED = {
address: "0x" + "c".repeat(40),
balance: "1.5",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
};
// Part priced, part not: 1.5 ETH plus a token with no price.
const PARTLY_PRICED = {
address: "0x" + "d".repeat(40),
balance: "1.5",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "5000.0" }],
};
beforeEach(() => {
clearPrices();
prices.ETH = 2000;
prices.USDC = 1;
state.wallets = [];
state.trackedTokens = [];
state.showZeroBalanceTokens = false;
state.activeAddress = null;
});
afterEach(() => {
clearPrices();
});
// The total line only, in each of the two markup-returning call sites. The
// ETH balance line above it legitimately reads $0.00 for an address with no
// ETH, so the assertions have to name the line under test.
function walletListTotal(addr) {
state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [addr] }];
const match = walletListHtml().match(/min-h-\[1rem\]">([^<]*)</);
return match && match[1];
}
function removalWarningTotal(addr) {
const match = balanceWarningHtml(addr).match(/mt-1">([^<]*)</);
return match && match[1];
}
describe("the value of an address, and whether it is the whole value", () => {
test("an address holding only unpriced tokens has an incomplete value", () => {
expect(getAddressValue(UNPRICED_ONLY)).toEqual({
usd: 0,
partial: true,
});
});
test("an address holding nothing is complete, and zero", () => {
expect(getAddressValue(EMPTY)).toEqual({ usd: 0, partial: false });
});
test("a fully priced address is complete, and unchanged", () => {
expect(getAddressValue(FULLY_PRICED)).toEqual({
usd: 5500,
partial: false,
});
});
test("a partly priced address keeps the part it can price", () => {
expect(getAddressValue(PARTLY_PRICED)).toEqual({
usd: 3000,
partial: true,
});
});
// A token balance of zero is not a holding, so it cannot make the total
// incomplete: an address with a spent-out unpriced token is worth zero.
test("a zero balance in an unpriced token leaves the value complete", () => {
const addr = {
address: "0x1",
balance: "0",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "0" }],
};
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
});
// Before the first price fetch, and on testnet, nothing is knowable: that
// is a third state, and it stays distinct from both of the others.
test("no prices at all means no value, not an incomplete one", () => {
clearPrices();
expect(getAddressValue(FULLY_PRICED)).toEqual({
usd: null,
partial: false,
});
});
test("one unpriced holding makes a wallet and the grand total partial", () => {
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
});
test("a wallet of fully priced addresses stays complete", () => {
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
});
});
describe("how that value is written on screen", () => {
test("a complete total is the figure", () => {
expect(formatAddressTotal(getAddressValue(FULLY_PRICED))).toBe(
"Total: $5,500.00",
);
});
test("an address worth zero says so", () => {
expect(formatAddressTotal(getAddressValue(EMPTY))).toBe("Total: $0.00");
});
// The figure is still worth having — it is the ETH the user does hold —
// but on its own it understates the address, so it is named as partial.
test("a partly priced total is given, and marked as partial", () => {
expect(formatAddressTotal(getAddressValue(PARTLY_PRICED))).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
// Nothing priced is held, so there is no figure to give: printing the
// $0.00 sum of an empty set is the bug.
test("a total with nothing priced in it gives no figure", () => {
const line = formatAddressTotal(getAddressValue(UNPRICED_ONLY));
expect(line).toBe("Total: unpriced tokens only");
expect(line).not.toContain("$");
});
test("an unknown value is written as nothing at all", () => {
clearPrices();
expect(formatAddressTotal(getAddressValue(FULLY_PRICED))).toBe("");
});
});
describe("the wallet list on Home", () => {
test("an address holding only unpriced tokens is not totalled at $0.00", () => {
expect(walletListTotal(UNPRICED_ONLY)).toBe(
"Total: unpriced tokens only",
);
});
test("an address holding nothing is still totalled at $0.00", () => {
expect(walletListTotal(EMPTY)).toBe("Total: $0.00");
});
test("a fully priced address shows its total", () => {
expect(walletListTotal(FULLY_PRICED)).toBe("Total: $5,500.00");
});
test("a partly priced address shows the priced part, marked partial", () => {
expect(walletListTotal(PARTLY_PRICED)).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
test("an address whose value is unknown keeps its blank line", () => {
clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
});
});
describe("the balance warning on the address-removal confirmation", () => {
// "This address holds a balance." followed by "Total: $0.00" is a flat
// contradiction, on the one screen whose job is to warn.
test("an address holding only unpriced tokens is not totalled at $0.00", () => {
expect(balanceWarningHtml(UNPRICED_ONLY)).toContain(
"This address holds a balance.",
);
expect(removalWarningTotal(UNPRICED_ONLY)).toBe(
"Total: unpriced tokens only",
);
});
test("a fully priced address still shows its total", () => {
expect(removalWarningTotal(FULLY_PRICED)).toBe("Total: $5,500.00");
});
test("a partly priced address shows the priced part, marked partial", () => {
expect(removalWarningTotal(PARTLY_PRICED)).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
test("no total line is written when the value is unknown", () => {
clearPrices();
expect(removalWarningTotal(FULLY_PRICED)).toBe(null);
});
});

490
tests/alarms.test.js Normal file
View File

@@ -0,0 +1,490 @@
// Scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// so anything scheduled with setInterval/setTimeout dies with it. These tests
// pin the recurring jobs to the alarms API and to the re-registration path a
// revived worker runs.
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
let mockNow = 0;
const mockBalanceRefreshAt = [];
// jest.resetModules() clears the call record of every jest.fn, and loading the
// worker is exactly that call — so anything that must be counted across a load
// is counted here rather than read off a mock.
let mockSetIntervalCalls = 0;
// Extension storage reads do not take a constant amount of time, and that is
// what makes a guard timed to the alarm period bite: backgroundRefresh()
// stamps its freshness marker after awaiting loadState(), so any read that is
// quicker than the previous one puts the next tick inside a guard of exactly
// one period and the tick is skipped. A simulation with a constant latency
// would sit exactly on the boundary and hide the bug.
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
let mockStorageJitter = false;
let mockStorageOpCount = 0;
function mockStorageTick() {
if (!mockStorageJitter) return;
mockNow +=
MOCK_STORAGE_LATENCIES_MS[
mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
];
}
jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now());
}),
getProvider: jest.fn(() => ({})),
}));
function makeAlarmsStub() {
const alarms = new Map();
const listeners = [];
const stub = {
created: [],
alarms,
create: jest.fn((name, info) => {
stub.created.push({ name, info });
alarms.set(name, { name, ...info });
}),
get: jest.fn(async (name) => alarms.get(name)),
clear: jest.fn(async (name) => alarms.delete(name)),
onAlarm: {
addListener: jest.fn((fn) => listeners.push(fn)),
},
fire: (name) => {
for (const fn of listeners) fn({ name });
},
listenerCount: () => listeners.length,
};
return stub;
}
describe("alarms module", () => {
let alarmsStub;
let alarmsMod;
beforeEach(() => {
jest.resetModules();
alarmsStub = makeAlarmsStub();
global.chrome = { alarms: alarmsStub };
alarmsMod = require("../src/shared/alarms");
});
afterEach(() => {
delete global.chrome;
});
test("ensureRecurringAlarms schedules the recurring job", async () => {
const created = await alarmsMod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, cleared: [] });
const names = alarmsStub.created.map((c) => c.name);
expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
});
test("the balance refresh keeps its 60-second cadence", async () => {
await alarmsMod.ensureRecurringAlarms();
const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance.periodInMinutes).toBe(1);
});
test("a retired job's alarm is cleared, not left running", async () => {
// The browser holds an alarm until something clears it. Deleting the
// job from the code is not enough: on every install that ever ran the
// version which created it, the alarm goes on waking the service
// worker on its old schedule with nothing to deliver it to.
for (const name of alarmsMod.OBSOLETE_ALARMS) {
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
}
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
const result = await alarmsMod.ensureRecurringAlarms();
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
for (const name of alarmsMod.OBSOLETE_ALARMS) {
expect(alarmsStub.alarms.get(name)).toBeUndefined();
}
});
test("clearing a retired alarm is not re-reported once it is gone", async () => {
await alarmsMod.ensureRecurringAlarms();
const again = await alarmsMod.ensureRecurringAlarms();
expect(again.cleared).toEqual([]);
});
test("no retired name is also a live one", async () => {
// A name in both lists would be created and then cleared on every
// start, so the job it schedules would never fire.
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
alarmsMod.BALANCE_REFRESH_ALARM,
);
});
test("no period is below the browser-enforced minimum", async () => {
// A period under one minute is silently clamped by the browser, so a
// request for one would mean the documented cadence is not the real
// one. Every period must be a whole minute at or above the minimum.
await alarmsMod.ensureRecurringAlarms();
for (const { info } of alarmsStub.created) {
expect(info.periodInMinutes).toBeGreaterThanOrEqual(
alarmsMod.MIN_ALARM_PERIOD_MINUTES,
);
expect(Number.isInteger(info.periodInMinutes)).toBe(true);
}
});
test("a revived worker does not reset an existing alarm's schedule", async () => {
await alarmsMod.ensureRecurringAlarms();
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
// Every wake re-runs the startup path. Re-creating an alarm restarts
// its period, so a busy extension would push the next fire out
// forever and the job would never run.
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, cleared: [] });
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
});
test("a missing alarm is re-created on the next start", async () => {
await alarmsMod.ensureRecurringAlarms();
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: true, cleared: [] });
expect(
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
).toBeDefined();
});
test("an alarm left over with a stale period is re-created", async () => {
// An install carries its alarms across an extension update, so a
// period changed in a new release only ever reaches users if the
// stale one is reconciled.
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 7 * 24 * 60,
});
alarmsStub.create.mockClear();
const created = await alarmsMod.ensureRecurringAlarms();
expect(created.balance).toBe(true);
expect(
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
.periodInMinutes,
).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
});
test("reconciling a period settles instead of re-creating forever", async () => {
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 30,
});
await alarmsMod.ensureRecurringAlarms();
alarmsStub.create.mockClear();
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, cleared: [] });
expect(alarmsStub.create).not.toHaveBeenCalled();
});
test("handlers are dispatched by alarm name from one listener", () => {
const balance = jest.fn();
const other = jest.fn();
expect(
alarmsMod.registerAlarmHandlers({
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
"autistmask-some-other-job": other,
}),
).toBe(true);
expect(alarmsStub.listenerCount()).toBe(1);
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance).toHaveBeenCalledTimes(1);
expect(other).not.toHaveBeenCalled();
alarmsStub.fire("autistmask-some-other-job");
expect(other).toHaveBeenCalledTimes(1);
alarmsStub.fire("an-alarm-with-no-handler");
expect(balance).toHaveBeenCalledTimes(1);
expect(other).toHaveBeenCalledTimes(1);
});
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
// Both targets are built from one bundle. MV2 has a persistent
// background page, but it takes the alarm path too, so the schedule
// is the same code on both browsers.
jest.resetModules();
const firefoxAlarms = makeAlarmsStub();
global.browser = { alarms: firefoxAlarms };
try {
const mod = require("../src/shared/alarms");
const created = await mod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, cleared: [] });
expect(firefoxAlarms.created).toHaveLength(1);
// The Chrome stub must not have been touched.
expect(alarmsStub.create).not.toHaveBeenCalled();
} finally {
delete global.browser;
}
});
test("a context without the alarms API degrades instead of throwing", async () => {
jest.resetModules();
delete global.chrome;
const mod = require("../src/shared/alarms");
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
balance: false,
cleared: [],
});
expect(mod.registerAlarmHandlers({})).toBe(false);
});
});
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
function loadBackground(initialStore = {}) {
const storageStore = initialStore;
const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
remove: async (key) => {
delete storageStore[key];
},
},
},
runtime: {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
onInstalled: {
addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
},
onStartup: {
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
},
getURL: (p) => "chrome-extension://test/" + p,
lastError: null,
},
windows: {
onRemoved: { addListener: jest.fn() },
create: jest.fn(),
},
tabs: { query: jest.fn(), sendMessage: jest.fn() },
action: { setPopup: jest.fn() },
};
// Present so that a startup path which went to the network would be
// recorded rather than throwing, which is what makes "no request was made"
// an observation instead of an assumption.
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({}),
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, store: storageStore };
}
// Flush the promise chains the startup path and the alarm handlers run on.
async function settle() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
}
describe("background worker scheduling", () => {
let alarmsStub;
let timers;
beforeEach(() => {
mockSetIntervalCalls = 0;
timers = {
setInterval: jest
.spyOn(global, "setInterval")
.mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
}),
};
});
afterEach(() => {
timers.setInterval.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("startup schedules the recurring jobs as alarms, not timers", async () => {
alarmsStub = loadBackground().alarmsStub;
// Let the startup path's promises settle.
await settle();
const names = alarmsStub.created.map((c) => c.name);
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
expect(names).toEqual([BALANCE_REFRESH_ALARM]);
expect(mockSetIntervalCalls).toBe(0);
});
test("startup contacts nothing", async () => {
// The phishing blocklist is vendored at build time and there is no
// other startup fetch, so a worker coming up asks nobody anything.
// Every wake used to be a candidate for a blocklist download.
loadBackground();
await settle();
expect(global.fetch).not.toHaveBeenCalled();
});
test("an onAlarm listener is installed on startup", async () => {
alarmsStub = loadBackground().alarmsStub;
await settle();
expect(alarmsStub.listenerCount()).toBe(1);
});
test("onInstalled and onStartup both re-establish the schedule", async () => {
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
await settle();
expect(loaded.listeners.onInstalled).toHaveLength(1);
expect(loaded.listeners.onStartup).toHaveLength(1);
// A browser start after the alarms were dropped must put them back.
alarmsStub.alarms.clear();
alarmsStub.created.length = 0;
loaded.listeners.onStartup[0]();
await settle();
expect(alarmsStub.created).toHaveLength(1);
});
test("the install-time listener and the top-level call share one run", async () => {
// On a fresh install both fire, close enough that both could observe
// an alarm missing and create it — and a second create restarts the
// period the first one just set.
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
loaded.listeners.onInstalled[0]();
await settle();
expect(alarmsStub.created).toHaveLength(1);
expect(alarmsStub.created.map((c) => c.name)).toEqual([
"autistmask-balance-refresh",
]);
});
});
// The alarm period alone must set the cadence. A freshness guard timed to the
// period vetoes the very tick it gates, because the guard is measured from
// when the last run finished and the alarm fires one run-duration before that.
// These tests measure the interval between refreshes that actually ran.
describe("balance refresh steady-state cadence", () => {
const {
BALANCE_REFRESH_PERIOD_MINUTES,
BALANCE_REFRESH_ALARM,
} = require("../src/shared/alarms");
const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let timerSpy;
function seededStore() {
return {
autistmask: {
hasWallet: true,
wallets: [
{ address: "0x0000000000000000000000000000000000000001" },
],
lastBalanceRefresh: 0,
},
};
}
beforeEach(() => {
mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
mockBalanceRefreshAt.length = 0;
mockSetIntervalCalls = 0;
mockStorageOpCount = 0;
mockStorageJitter = false;
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
});
});
afterEach(() => {
mockStorageJitter = false;
clockSpy.mockRestore();
timerSpy.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("ten alarm ticks produce ten refreshes, one per period", async () => {
const { alarmsStub } = loadBackground(seededStore());
await settle();
mockStorageJitter = true;
const TICKS = 10;
let tickAt = mockNow + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
mockNow = tickAt;
tickAt += PERIOD_MS;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
}
// No tick was a no-op. This is the assertion that fails when the guard
// is timed to the alarm period.
expect(mockBalanceRefreshAt).toHaveLength(TICKS);
// And the observed cadence is one period, not two.
const intervals = mockBalanceRefreshAt
.slice(1)
.map((t, i) => t - mockBalanceRefreshAt[i]);
for (const interval of intervals) {
expect(interval).toBeGreaterThanOrEqual(
PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
);
expect(interval).toBeLessThanOrEqual(
PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
);
}
});
test("a refresh an open popup just did still suppresses the tick", async () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const store = seededStore();
const { alarmsStub } = loadBackground(store);
await settle();
mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
expect(mockBalanceRefreshAt).toHaveLength(0);
});
});

309
tests/approvalTx.test.js Normal file
View File

@@ -0,0 +1,309 @@
// Preparation of the transaction the approval screen displays.
//
// This is the half of the fix that makes the verification in
// approvalVerify.test.js mean anything: the numbers the user reads have to be
// produced before the screen is drawn and be the numbers that get signed. What
// is asserted here is that the object leaving this module is complete (nothing
// is left for the popup to fill in), that it survives the messaging boundary
// (extension messaging is JSON, which has no bigint), and that nothing the
// requesting page or the RPC node can say turns it into an approval that
// should never have been raised.
const { Network, Wallet } = require("ethers");
const {
prepareApprovalTx,
serializeApprovedTx,
POPULATE_TIMEOUT_MS,
} = require("../src/shared/approvalTx");
const {
SERIALIZED_FIELDS,
MAX_FEE_PER_GAS,
MAX_GAS_LIMIT,
} = require("../src/shared/approvalVerify");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The ordinary dApp request: recipient, value, call data, and nothing else.
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0xdeadbeef",
};
function providerWith(overrides) {
return {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => 7,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
...(overrides || {}),
};
}
// A node that only quotes a flat gas price, so populateTransaction produces a
// legacy transaction rather than an EIP-1559 one.
const legacyProvider = providerWith({
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
}),
});
describe("prepareApprovalTx", () => {
test("fills in everything the request left out", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(approved).toEqual({
type: 2,
from: signer.address,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: TX_PARAMS.value,
data: TX_PARAMS.data,
accessList: [],
});
});
// The object is displayed, signed and verified against on the far side of
// chrome.runtime.sendMessage, which is JSON: a bigint would throw on the
// way out and a field that did not survive the trip would be a field the
// user was shown and nothing compared.
test("survives the messaging boundary unchanged", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(JSON.parse(JSON.stringify(approved))).toEqual(approved);
for (const value of Object.values(approved)) {
expect(typeof value).not.toBe("bigint");
}
});
test("carries exactly the fields its type serializes, and the signer", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(Object.keys(approved).sort()).toEqual(
["type", "from", ...SERIALIZED_FIELDS[2]].sort(),
);
});
test("produces a legacy transaction when that is all the node quotes", async () => {
const approved = await prepareApprovalTx(
legacyProvider,
signer.address,
TX_PARAMS,
);
expect(approved.type).toBe(0);
expect(approved.gasPrice).toBe("0x77359400");
expect(approved.maxFeePerGas).toBeUndefined();
expect(Object.keys(approved).sort()).toEqual(
["type", "from", ...SERIALIZED_FIELDS[0]].sort(),
);
});
test("keeps a nonce, gas limit and fee the request did fix", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
nonce: "0x2",
gasLimit: "0x30d40",
maxFeePerGas: "0x12a05f200",
maxPriorityFeePerGas: "0x3b9aca00",
},
);
expect(approved.nonce).toBe("0x2");
expect(approved.gasLimit).toBe("0x30d40");
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
test("carries an access list the request asked for", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
accessList: [{ address: RECIPIENT, storageKeys: [] }],
},
);
expect(approved.accessList).toEqual([
{ address: RECIPIENT, storageKeys: [] },
]);
});
// The request is page-controlled. Anything this wallet does not act on is
// dropped before ethers sees it, so a field a future ethers learns to
// carry cannot be picked up out of it without this module knowing.
test("drops request fields this wallet does not act on", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
authorizationList: [{ address: RECIPIENT }],
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
customData: { anything: true },
},
);
expect(approved.authorizationList).toBeUndefined();
expect(approved.blobVersionedHashes).toBeUndefined();
expect(approved.customData).toBeUndefined();
expect(approved.type).toBe(2);
});
test("refuses a transaction type this wallet does not sign", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
type: 4,
}),
).rejects.toThrow(/type this wallet does not sign/);
});
test("refuses to raise an approval with no active address", async () => {
await expect(
prepareApprovalTx(providerWith(), null, TX_PARAMS),
).rejects.toThrow(/no active address/);
});
// The ceilings as a backstop: equality with the screen cannot bound what
// the node talks the wallet into putting on the screen, so it is refused
// before the user is shown anything.
test("refuses a fee the node quoted above the ceiling", async () => {
const gouging = providerWith({
getFeeData: async () => ({
gasPrice: MAX_FEE_PER_GAS + 1n,
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
maxPriorityFeePerGas: 1000000000n,
}),
});
await expect(
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
).rejects.toThrow(/fee per gas far above any plausible value/);
});
test("refuses a gas limit the node estimated above the ceiling", async () => {
const absurd = providerWith({
estimateGas: async () => MAX_GAS_LIMIT + 1n,
});
await expect(
prepareApprovalTx(absurd, signer.address, TX_PARAMS),
).rejects.toThrow(/gas limit no network this wallet supports/);
});
// No approval and no window: the failure goes back to the page the click
// came from, in a sentence.
test("reports a failed estimate as a full sentence", async () => {
const reverting = providerWith({
estimateGas: async () => {
throw new Error("execution reverted: ERC20: transfer amount");
},
});
let thrown;
try {
await prepareApprovalTx(reverting, signer.address, TX_PARAMS);
} catch (e) {
thrown = e;
}
expect(thrown.message).toMatch(
/^The transaction could not be prepared/,
);
expect(thrown.message).toMatch(/execution reverted/);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
});
// Without a bound, an unreachable node leaves the page's promise pending
// with nothing on screen to explain it.
test("gives up on a node that never answers", async () => {
jest.useFakeTimers();
try {
const hanging = providerWith({
estimateGas: () => new Promise(() => {}),
});
const pending = prepareApprovalTx(
hanging,
signer.address,
TX_PARAMS,
);
const settled = expect(pending).rejects.toThrow(
/did not answer in time/,
);
await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
await settled;
} finally {
jest.useRealTimers();
}
});
});
describe("serializeApprovedTx", () => {
// Unreachable through prepareApprovalTx while the request type is checked
// first, which is what it is for: a node or an ethers upgrade that
// populates a type this wallet does not sign must not produce an approval.
test("refuses a populated transaction of a type this wallet does not sign", () => {
expect(() =>
serializeApprovedTx(
{ type: 3, to: RECIPIENT, nonce: 7 },
signer.address,
),
).toThrow(/type this wallet does not sign/);
});
test("refuses a populated transaction missing a quantity", () => {
expect(() =>
serializeApprovedTx(
{
type: 2,
chainId: 1n,
nonce: 7,
gasLimit: 21000n,
maxFeePerGas: 2000000000n,
to: RECIPIENT,
value: 0n,
data: "0x",
},
signer.address,
),
).toThrow(/did not supply a maxPriorityFeePerGas/);
});
test("keeps a contract creation's absent recipient absent", () => {
const approved = serializeApprovedTx(
{
type: 0,
chainId: 1n,
nonce: 7,
gasPrice: 2000000000n,
gasLimit: 21000n,
to: null,
value: 0n,
data: "0x600160005500",
},
signer.address,
);
expect(approved.to).toBeNull();
expect(approved.value).toBe("0x0");
expect(approved.data).toBe("0x600160005500");
});
});

1426
tests/approvalVerify.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,329 @@
// Back after reopening the popup (#268).
//
// A reopened popup renders the wallet list and the one view it restores
// onto; every other view is still the blank static template from
// index.html. goBack() used to only unhide its target, so Back landed on
// that blank template for any view the popup had not rendered in this page
// load. These tests drive the real goBack() with the real router wired to
// recording view modules, so what is asserted is which view render ran —
// the thing that was missing.
//
// The rendering itself is asserted against the real popup in a real
// browser by tests/e2e/run.js; here the DOM is a stub, because goBack()
// only needs showView() to work.
const els = new Map();
function fakeEl() {
return {
textContent: "",
innerHTML: "",
classList: {
toggle() {},
add() {},
remove() {},
contains: () => false,
},
remove() {},
};
}
globalThis.document = {
getElementById(id) {
if (!els.has(id)) els.set(id, fakeEl());
return els.get(id);
},
};
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const {
showView,
goBack,
setBackRenderer,
pushCurrentView,
} = require("../src/popup/views/helpers");
const {
makeBackRenderer,
markViewRendered,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const { state } = require("../src/shared/state");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
let calls;
// Stand-ins for the view modules. Each records itself and then shows its
// view, which is what every real view render ends with — so the assertions
// can tell "rendered and shown" apart from "merely unhidden".
function recorder(name, view) {
return () => {
calls.push(name);
showView(view);
};
}
function makeViews() {
return {
main: { show: recorder("main", "main") },
addressDetail: { show: recorder("addressDetail", "address") },
addressToken: { show: recorder("addressToken", "address-token") },
receive: { show: recorder("receive", "receive") },
settings: { show: recorder("settings", "settings") },
settingsAddToken: {
show: recorder("settingsAddToken", "settings-addtoken"),
},
confirmTx: { restore: recorder("confirmTx", "confirm-tx") },
transactionDetail: {
render: recorder("transactionDetail", "transaction"),
},
txStatus: {
restoreWait: () => {
calls.push("waitTx");
showView("wait-tx");
return true;
},
renderSuccess: recorder("successTx", "success-tx"),
renderError: recorder("errorTx", "error-tx"),
},
};
}
// The popup as it stands just after a reopen: one wallet with one address,
// the view the popup restored onto, and the stack behind it.
//
// A reopen is a fresh page load, so the record of what has been rendered
// starts empty — that emptiness is what makes the Back path render at all.
// Returns the view modules so a test can drive forward navigation through
// the same recorders the router renders through.
function reopenedOn(view, stack, extra) {
calls = [];
resetRenderedViews();
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = null;
state.viewData = null;
state.currentView = view;
state.viewStack = stack.slice();
Object.assign(state, extra || {});
// Restoring onto a view renders it, so the reopened popup has that one
// view on the page and nothing else.
markViewRendered(view);
const views = makeViews();
setBackRenderer(makeBackRenderer(state, views));
return views;
}
// The reproduction from the issue, step for step.
describe("Back onto a view the reopened popup never rendered", () => {
test("Back from settings renders the address detail underneath", () => {
reopenedOn("settings", ["main", "address"]);
goBack();
expect(calls).toEqual(["addressDetail"]);
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
test("Back onto the token detail renders it", () => {
reopenedOn("settings", ["main", "address", "address-token"], {
selectedToken: TOKEN,
});
goBack();
expect(calls).toEqual(["addressToken"]);
expect(state.currentView).toBe("address-token");
});
test("Back onto Receive renders it", () => {
reopenedOn("settings", ["main", "address", "receive"]);
goBack();
expect(calls).toEqual(["receive"]);
expect(state.currentView).toBe("receive");
});
test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead" } },
});
goBack();
expect(calls).toEqual(["transactionDetail"]);
expect(state.currentView).toBe("transaction");
});
test("Back onto the transaction confirmation restores it", () => {
reopenedOn("settings", ["main", "confirm-tx"], {
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
});
goBack();
expect(calls).toEqual(["confirmTx"]);
expect(state.currentView).toBe("confirm-tx");
});
test("Back onto the success screen renders it", () => {
reopenedOn("settings", ["main", "success-tx"], {
viewData: { hash: "0xdead" },
});
goBack();
expect(calls).toEqual(["successTx"]);
expect(state.currentView).toBe("success-tx");
});
test("Back onto the failure screen renders it", () => {
reopenedOn("settings", ["main", "error-tx"], {
viewData: { message: "execution reverted" },
});
goBack();
expect(calls).toEqual(["errorTx"]);
expect(state.currentView).toBe("error-tx");
});
test("Back onto Home renders the wallet list", () => {
reopenedOn("settings", ["main"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("Back with an empty stack renders Home", () => {
reopenedOn("settings", []);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});
// The guards are restoreView()'s, so a popped view whose backing data is
// gone lands on Home rather than on an empty template.
describe("Back onto a view whose backing data is gone", () => {
test("the token detail with no token selected falls back to Home", () => {
reopenedOn("settings", ["main", "address-token"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the transaction detail with no transaction falls back to Home", () => {
reopenedOn("settings", ["main", "transaction"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the confirmation with no pending transaction falls back to Home", () => {
reopenedOn("settings", ["main", "confirm-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("an address view with no address selected falls back to Home", () => {
reopenedOn("settings", ["main", "receive"], {
selectedAddress: null,
});
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the success screen with no transaction hash falls back to Home", () => {
reopenedOn("settings", ["main", "success-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the failure screen with no message falls back to Home", () => {
reopenedOn("settings", ["main", "error-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("a wait that can no longer be resumed falls back to Home", () => {
reopenedOn("settings", ["main", "wait-tx"]);
const views = makeViews();
views.txStatus.restoreWait = () => false;
setBackRenderer(makeBackRenderer(state, views));
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});
// Forward navigation renders as it goes, and a second render would re-fetch
// and clobber whatever the view holds — an unsaved edit, a request in
// flight. So the Back path renders only a view this page load has never
// rendered, and merely unhides every other one: the views it does not
// render from persisted state, and the views already on the page.
describe("what the Back path leaves alone", () => {
test("forward navigation renders nothing by itself", () => {
reopenedOn("address", ["main"]);
pushCurrentView();
showView("send");
expect(calls).toEqual([]);
expect(state.viewStack).toEqual(["main", "address"]);
});
test("Back onto a live-session view only unhides it", () => {
reopenedOn("confirm-tx", ["main", "address", "send"]);
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("send");
});
test("Back renders its target exactly once", () => {
reopenedOn("settings", ["main", "address"]);
goBack();
expect(calls.filter((c) => c === "addressDetail")).toHaveLength(1);
});
test("Back onto a view this page load already rendered only unhides it", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.addressDetail.show();
pushCurrentView();
views.settings.show();
calls = [];
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("address");
});
// The unit mirror of the regression the browser suite pins: Settings
// reassigns its fields from persisted state on every render, so a
// re-render on the way back discards an edit the user has not saved.
test("Back onto Settings visited earlier in this page load does not re-render it", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.settings.show();
pushCurrentView();
views.settingsAddToken.show();
calls = [];
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("settings");
});
// Home is the deliberate exception, unchanged from the popup's
// behaviour before the router existed: it re-renders on every Back so
// the wallet list reflects what changed while the user was away.
test("Back onto Home renders it again even when it is already on the page", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.addressDetail.show();
calls = [];
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
const {
DEBUG,
BUILD_DEBUG_MARKER,
ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL,
BIP44_ETH_PATH,
@@ -19,6 +21,24 @@ describe("constants", () => {
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
});
// This does not replace script/verify-build, which is the only thing that
// can see the compiled DEBUG state of a real bundle. It pins the source
// invariant that the marker tracks DEBUG, so the two cannot be edited
// apart and leave verify-build asserting something that is no longer the
// flag the code branches on.
test("build debug marker is derived from DEBUG", () => {
expect(BUILD_DEBUG_MARKER).toBe(
DEBUG ? "autistmask-build-debug=on" : "autistmask-build-debug=off",
);
});
// Outside a bundle there is no __BUILD_DEBUG__ define, and the fallback
// must be the safe one.
test("DEBUG is off when loaded outside a bundle", () => {
expect(DEBUG).toBe(false);
expect(BUILD_DEBUG_MARKER).toBe("autistmask-build-debug=off");
});
test("exports ERC-20 ABI with expected functions", () => {
expect(Array.isArray(ERC20_ABI)).toBe(true);
expect(ERC20_ABI.length).toBeGreaterThan(0);

158
tests/deleteAddress.test.js Normal file
View File

@@ -0,0 +1,158 @@
// Tests for the copy on the address-removal confirmation (issue #162).
//
// The screen's whole job is to warn before a destructive-looking action, so
// the copy is the substance and is tested as such. Two things it must not
// get wrong: what it takes to get the address back — the app refuses both
// obvious routes — and what counts as holding something, which is any
// ERC-20 as well as ETH, at any size, including a balance that rounds to
// zero at the four decimals the balance lines render. The DOM behaviour
// around them is driven against the real popup by tests/e2e/run.js.
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { addressHoldsFunds } = require("../src/popup/views/helpers");
const {
recoveryPathText,
balanceWarningHtml,
} = require("../src/popup/views/deleteAddress");
const { prices, clearPrices } = require("../src/shared/prices");
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
const TOKEN_ONLY = {
address: "0x1",
balance: "0.0000",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
};
const ZERO_TOKEN = {
address: "0x1",
balance: "0",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "0" }],
};
afterEach(() => {
clearPrices();
});
describe("what the screen says it takes to get the address back", () => {
// The screen used to promise the address "can be brought back at any
// time by importing this wallet's recovery phrase again". That import is
// refused as a duplicate for as long as the wallet is present, which it
// always is here — a wallet never gives up its last address.
test("it does not promise a re-import while the wallet is here", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).not.toMatch(/at any time/);
expect(text).toContain("is refused while this wallet is still here");
});
test("it names deleting the whole wallet as the route back", () => {
expect(recoveryPathText({ type: "hd" })).toContain(
"delete the whole wallet in Settings",
);
});
// The scan after a re-import finds used addresses only, so an address
// that never saw a transaction does not come back at all. Saying so is
// the difference between a warning and a false reassurance.
test("it states the limit: only on-chain activity is found", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).toContain("only finds addresses that have on-chain");
expect(text).toContain("never been used is not found by it");
});
// The screen is offered on xprv wallets too, and an xprv wallet holds no
// recovery phrase — telling its owner to import one would send them
// looking for words that do not exist.
test("an xprv wallet is told about its extended private key", () => {
const text = recoveryPathText({ type: "xprv" });
expect(text).toContain("extended private key");
expect(text).not.toContain("recovery phrase");
});
test("an HD wallet is told about its recovery phrase", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).toContain("recovery phrase");
expect(text).not.toContain("extended private key");
});
});
describe("whether an address holds anything", () => {
test("ETH counts", () => {
expect(addressHoldsFunds(ETH_ONLY)).toBe(true);
});
// The case that decides the screen: no ETH at all, and $2500 of a
// stablecoin sitting at the address.
test("an ERC-20 balance counts even with no ETH", () => {
expect(addressHoldsFunds(TOKEN_ONLY)).toBe(true);
});
// 0.00001 ETH renders as "0.0000" at four decimals. It is still money.
test("an ETH balance below the displayed precision counts", () => {
expect(addressHoldsFunds(DUST)).toBe(true);
});
test("an address holding nothing does not", () => {
expect(addressHoldsFunds(EMPTY)).toBe(false);
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
});
test("a missing address or missing fields do not", () => {
expect(addressHoldsFunds(undefined)).toBe(false);
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
});
});
describe("the balance warning on the removal confirmation", () => {
test("an address holding nothing gets a blank line, not a warning", () => {
expect(balanceWarningHtml(EMPTY)).toBe("&nbsp;");
expect(balanceWarningHtml(ZERO_TOKEN)).toBe("&nbsp;");
});
test("an ERC-20-only address is warned about, and its token listed", () => {
const html = balanceWarningHtml(TOKEN_ONLY);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("does not move or spend anything");
expect(html).toContain("USDC");
expect(html).toContain("2500.0000");
});
// The rendered line says 0.0000 for this address — that is the display
// format, shared with Home and AddressDetail — and the warning is shown
// all the same, because the balance is not zero.
test("an ETH balance that renders as 0.0000 is warned about", () => {
const html = balanceWarningHtml(DUST);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("<span>0.0000</span>");
});
// The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money.
test("the warning sentence asserts no rounded amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch(
/holds [\d.]+ (ETH|USDC)/,
);
}
});
test("the USD total is shown when prices are known", () => {
prices.ETH = 2000;
prices.USDC = 1;
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
});
// getAddressValue() reports no value on testnet and before the first
// price fetch. A "Total: $0.00" there would be a lie about the holdings.
test("no USD total is shown when prices are not known", () => {
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");
});
});

Some files were not shown because too many files have changed in this diff Show More