51 Commits
Author SHA1 Message Date
clawbot bb60b399ec test: tighten two checks in the persisted-field harness (closes #379)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The polarity check now counts only `hostile` and `falsy` values, not a
`hostileRestore` value, which reaches only the views its entry names.
The stale index 5 moves into `hostile` for `selectedWallet` and
`selectedAddress`, as each field's one value still truthy after the
floor.

Each `hostileRestore` entry declares whether its boot lands on its view
or falls back to Home, and the test checks the one view on screen. A
value driven onto every restorable view lists only the views it falls
back on, so a view added later is driven by default.

The header and the README restate the remaining limits as built and say
which boots are held to where the popup lands.

Model: opus-5-5
2026-10-07 11:59:14 +02:00
clawbot 447d714313 fix: show every password error in its own fixed-height line (closes #493)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The add wallet screen reported a missing, short or mismatched password in
the flash line at the top of the popup, and the private key export,
recovery phrase and delete wallet screens each wrote to a line of their own
above the password field. All four now use showError() and hideError() with
a fixed-height error line below the field, as the send confirmation and
approval screens do. On the add wallet screen the line sits beside the
Import button, which keeps its place at 360x600. The line clears when the
screen is shown again and when the password is tried again. Other add
wallet messages stay in the flash line.

Model: opus-5-5
2026-10-07 10:59:16 +02:00
clawbot 29ba54d5b6 docs: record the pre-1.0 security review in TODO.md (closes #383)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The security review moves from Next Step to Completed Steps, saying what it
read (the tree at 99292b9), that its ten findings are filed and fixed on next,
which owner decisions it raised are still open, and what it did not cover.
Next Step takes the one Future Steps item, cutting 1.0.0 once the milestone is
empty. Status drops a dated gate result and links the current milestone PR.

Model: opus-5-5
2026-10-07 09:43:07 +02:00
clawbot e3dd0e44da chore: prune landed remote branches (closes #167)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Eighteen branches on origin that the issue classifies as landed, or
superseded by merged pull requests, were deleted; the evidence for each
is on the issue. Four whose work is not in next are kept: the three
issue 87 error-display branches, reference for issue 493, and
chore/token-list-enrichment, re-created at f7a2437, pending issue 495.
TODO.md records this.

The branch-pruning Future Step had already left TODO.md in the issue 191
rewrite, so only the Completed Steps entry is added.

Model: opus-5-5
2026-10-07 09:09:07 +02:00
clawbot 860db6034c docs: README says why the wallet never clears the clipboard (closes #492)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The owner's ruling on #19 is
now in the README, under ExportPrivKey: copying the key leaves it on the
clipboard, because the clipboard is the user's, clearing it would go
against what they expect, and it could destroy something else they
copied since. ShowRecoveryPhrase copies the phrase the same way and
points back to it. Both describe the warning each password screen
shows on next, which does not mention the clipboard.

Model: opus-5-5
2026-10-07 08:09:06 +02:00
clawbot cd8e45ae0c test: the Firefox suite tolerates no extension error (closes #487)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The one entry in ALLOWED_ERRORS in tests/e2e/firefox/run.js, Firefox
reporting a popup promise that settled after the page unloaded, lost its
cause when the site-connection buttons stopped sending with an unawaited
sendMessage before closing (#275). Left in place it would also hide the
same error from any other popup code that sends and then closes. The
entry goes, with the code that only printed and set aside tolerated
errors, and the README paragraph that described it.

Model: opus-5-5
2026-10-07 07:26:09 +02:00
clawbot eeb10c20ef chore: draw the toolbar icons in-tree with make icons (closes #378)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
script/lib/icons.js draws the mark from geometry read back out of the
committed PNGs, since the coordinates were never recorded, and writes each
PNG with node's own zlib. `make icons` runs it and leaves alone a file that
already holds the drawn image.

tests/icons.test.js requires each committed file to hold exactly that
image: the same IHDR and every pixel. The compressed bytes are not
compared, because node's bundled zlib does not compress as the stock zlib
that made the committed files did; the decision is recorded on the issue.

build.js copies the manifests from MANIFEST_SOURCES instead of naming the
two paths a second time.

Model: opus-5-5
2026-10-07 06:43:08 +02:00
clawbot 0aaa94471f docs: README end-to-end limits match what the browser suites do (closes #293)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The window.close override the issue named was removed with #275, so it
needs no entry. Every other place either suite changes or works around
the shipped extension is now listed: the popup loaded in a tab, Chrome's
wait before each site-connection request, its recording wrapper and click
listener, its clipboard grant, two layout tests that write into the page,
the forced leave during a decrypt, and Firefox's setting that forces the
site-connection prompt into a window. Firefox's tolerated error is
described as the leftover it is, with #487 to remove it; the phishing
blocklist is no longer listed as a failing fetch, and two stale figures
are corrected.

Model: opus-5-5
2026-10-07 05:59:15 +02:00
clawbot 763b50b0e5 fix: Back from Settings never lands on Settings itself (closes #481)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The recovery phrase and delete wallet screens take themselves off the
Back stack when left, so Settings, one of those screens, then the
settings gear leaves Settings under the Settings now showing. A reopened
popup restores the same stack, cut at the screen the gear left. Back
then showed Settings again and seemed to do nothing.

goBack() now skips any entry for the screen already showing before it
pops its target. Jest tests drive the gear and then Back for the
recovery phrase screen, once and twice over, for both delete screens,
and after a reopen.

Model: opus-5-5
2026-10-07 05:26:06 +02:00
clawbot 4dafd88fad fix: discard-dist-on-failure keeps the step's status when its message cannot be written (closes #342)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
With stderr closed, the wrapper's message write failed and set -e ended it
with status 2; with stderr a pipe whose reader had gone, the write killed it
with 141. The message is now written with SIGPIPE ignored and its failure
ignored, after the step has run. An interrupt while a step runs now exits with
130 once the step has ended, removing nothing: under bash, a step that caught
the interrupt and exited with a status, as check-censored does, used to get
dist/ removed. A failed check-censored --require-dist still removes dist/. The
header states both. Also the README bullet's missing period.

Model: opus-5-5
2026-10-07 04:26:08 +02:00
clawbot 1cfd69e72d fix: Back from Settings no longer lands on a delete wallet screen left by the gear (closes #480)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Leaving the delete wallet or lost-password screen drops its wallet
selection, but the settings gear had just pushed the screen onto the Back
stack, so Back from Settings showed a screen whose button could only
answer "No wallet selected for deletion." Each screen's leave handler now
also takes it off the top of the stack, as the private key export and
recovery phrase screens do since
#461. Back from Settings then
stays on Settings once, as it does for the recovery phrase screen.

Jest tests drive the gear and then Back on both screens, and the delete
screen's own Back.

Model: opus-5-5
2026-10-07 03:43:08 +02:00
clawbot e14f6e9eb5 chore: script/bootstrap fails when node cannot find a dependency in package.json (closes #263)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
yarn install exits 0 without touching node_modules whenever
node_modules/.yarn-integrity matches yarn.lock, so a package deleted from
node_modules stayed deleted while bootstrap printed "bootstrap complete".
After the install, bootstrap now asks node for the package.json of every
package listed in dependencies and devDependencies of package.json, and on
the first it cannot find it names the package and the fix:
rm -rf node_modules && make bootstrap. A package whose exports hides its
package.json (ethers, libsodium-wrappers-sumo) makes node throw
ERR_PACKAGE_PATH_NOT_EXPORTED, which it does only after finding the
package, so that error counts as found.

Model: opus-5-5
2026-10-07 02:26:09 +02:00
clawbot e865099c5b fix: Back from Settings no longer lands on a secret screen left by the gear (closes #461)
check / check (push) Successful in 6m59s
e2e / e2e-chrome (push) Successful in 5m17s
e2e / e2e-firefox (push) Successful in 3m11s
Leaving the private key export or recovery phrase screen drops the
selection it was showing, but the settings gear had just pushed the
screen onto the Back stack, so Back from Settings landed on a password
prompt that could only fail. Each screen's leave handler now also takes
it off the top of the stack, which is what a reopened popup already does
to these screens. Back from Settings goes to the address screen for the
export screen; for the recovery phrase screen, opened from Settings, it
stays on Settings once, as after a reopen.

Jest tests drive the gear and then Back, and each screen's own Back, for
both screens; leavePrivkeyScreen() in the e2e suite expects the address
screen.

Model: opus-5-5
2026-10-07 01:43:07 +02:00
clawbot cd730241bb fix: a token symbol read off a contract is never stored cut mid-emoji (closes #458)
check / check (push) Successful in 6m10s
e2e / e2e-chrome (push) Successful in 5m25s
e2e / e2e-firefox (push) Successful in 3m4s
lookupTokenInfo() cut the symbol at 12 and the name at 64 UTF-16 units,
so an emoji outside the Basic Multilingual Plane could be cut between its
two halves and the half left over stored and shown as U+FFFD. Both are
now cut on code points, as displaySymbol() does. A symbol already stored
broken is not repaired.

Model: opus-5-5
2026-10-06 23:59:21 +02:00
clawbot ad1b4f0557 fix: a popup reload no longer logs the requests it cancels on most screens (closes #475)
check / check (push) Successful in 5m41s
e2e / e2e-chrome (push) Successful in 5m13s
e2e / e2e-firefox (push) Successful in 3m24s
The transaction lists and ENS name lookups on the address and token
screens, the address scan after a wallet is created, the endpoint checks
in Settings, the wait screen's receipt check, the Send screen's Max fee
estimate and the token lookup on both add-token screens now check the
signal the popup aborts on pagehide before reporting a failed request.
scanForAddresses(), resolveEnsNames() and lookupTokenInfo() take the
signal.

End-to-end tests reload the popup on the address screen and during the
address scan with their requests held. Jest tests show each of these
reports a real failure and stays silent once the popup has closed. The
transaction detail and confirmation screens are left out: they discard
the popup context that carries the signal.

Model: opus-5-5
2026-10-06 22:09:08 +02:00
clawbot cb23611a17 fix: the private key export screen opens again in the same session (closes #460)
check / check (push) Successful in 5m17s
e2e / e2e-chrome (push) Successful in 5m11s
e2e / e2e-firefox (push) Successful in 2m32s
show() found the address line through the element inside it, then
replaced the line's contents with renderAddressHtml(), which deleted
that element, so the next show() in the same popup session threw
before it navigated. The line now carries the export-privkey-address
id itself and is looked up by it. No other view that renders an
address finds its container through a child.

The jest DOM stub now takes an element out of the document when its
parent's contents are replaced, and a new test opens the screen
twice. The #253 e2e case no longer reopens the popup before its
second open.

Model: opus-5-5
2026-10-06 19:43:07 +02:00
clawbot 0206b2f77e chore: re-vendor canonical files from prompts at dd4027b (closes #472)
check / check (push) Successful in 5m51s
e2e / e2e-chrome (push) Successful in 5m3s
e2e / e2e-firefox (push) Successful in 2m33s
Copies .dockerignore, .gitignore, .prettierignore, check.yml and
REPO_POLICIES.md from sneak/prompts at dd4027b, keeping the repo's own
entries (dist/, release/, yarn files) after the canonical content.

The Dockerfile gets separate lint and test phases; its last stage
depends on both, checks the git describe version and runs make build.
script/lint, test, check, cibuild and docker are the canonical models.
check-censored moves into the lint phase and test-verify-build into the
test phase. fmt and fmt-check fall back to the nvm-installed node. The
e2e image builds are uncached. Comments citing the old test caps or what
runs a script are updated.

Model: opus-5-5
2026-10-06 15:59:21 +02:00
clawbot 329e64a54d fix: a popup reload mid-refresh no longer logs the requests it cancels (closes #218)
check / check (push) Successful in 2m50s
e2e / e2e-chrome (push) Successful in 5m53s
e2e / e2e-firefox (push) Successful in 2m19s
Chrome cancels a closing popup's open requests just after pagehide, and in
the page a cancelled fetch() fails with the same "Failed to fetch" as a
server that cannot be reached. The home screen's transaction list and the
balance refresh logged an error for each, and the e2e suite failed on them.

The popup now aborts an AbortController on pagehide, and those failure
reports check its signal first. End-to-end tests reload the popup with
Blockscout held and require nothing logged, and fail the transaction list
for real and require the failure reported; a unit test covers the balance
refresh. The address and token screens and the new-wallet address scan are
#475.

Model: opus-5-5
2026-10-06 09:43:09 +02:00
clawbot 88c79e7e05 chore: pin Tailwind 4.3.1, which drops module.register() (closes #355)
check / check (push) Successful in 2m44s
e2e / e2e-chrome (push) Successful in 5m39s
e2e / e2e-firefox (push) Successful in 3m43s
@tailwindcss/node 4.2.1, loaded by the Tailwind CLI, calls Node's
deprecated module.register() as it starts, so every make build on a
current Node printed the DEP0205 warning. 4.3.1 is the first release that
calls module.registerHooks() instead wherever Node has it.

tailwindcss and @tailwindcss/cli are pinned at exactly 4.3.1, and
yarn.lock carries the new versions of the packages they pull in. The
compiled CSS computes to the same values: it drops the unused .start and
.end rules and writes calc(var(--spacing) * 1) as var(--spacing).

Model: opus-5-5
2026-10-06 05:26:09 +02:00
clawbot 25ead55eac fix: make dev rebuilds dist/ on source changes (closes #332)
check / check (push) Successful in 1m17s
e2e / e2e-chrome (push) Successful in 4m10s
e2e / e2e-firefox (push) Successful in 51s
make dev passed --watch to a build.js that read no arguments, so it
built once and exited. build.js --watch now builds, then builds again
after every change to a file under src/, manifest/ or icons/, until
interrupted; any other argument fails. Each directory gets its own
watcher, because Node's recursive watch on Linux loses a file that an
editor saves by renaming a new copy over it. A watch build writes no
build receipt and cannot be verified; README.md and the Makefile say
so and point to make build.

Model: opus-5-5
2026-10-06 03:43:18 +02:00
clawbot a8452a1d46 chore: cap every CI job with timeout-minutes (closes #294)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
No workflow set timeout-minutes, so a hung build or browser held the
shared runner until the server's own limit, hours later. check now stops
at 10 minutes, e2e-firefox at 15 and e2e-chrome at 20: each is over two
and a half times the job's slowest cold-cache run. README "In CI" records
the measured times and the caps.

Model: opus-5-5
2026-10-06 01:09:05 +02:00
clawbot 3663f02bf5 chore: drop eth_chainId and net_version from PROXY_METHODS (closes #326)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s
handleRpc answers both methods itself before it reaches its proxy branch, so
the two list entries were never used and the list named two methods that are
never sent to the RPC endpoint. No other entry is answered earlier.

PROXY_METHODS is now exported from the background script so that
tests/proxyMethods.test.js can send every listed method from a page and fail
on any that does not reach the RPC endpoint.

Model: opus-5-5
2026-10-06 00:48:45 +02:00
clawbot e590b83df0 harden: drop 'unsafe-inline' from style-src (closes #328)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
The popup's markup no longer carries style attributes. The 42 in
index.html and in the HTML the view helpers build are now Tailwind
classes, each computing to the value it replaced, so style-src is 'self'
in both manifests, pinned in tests/manifest.test.js.

The address dot's 16 colours are written out as whole classes, because
Tailwind builds only the classes it finds in the source. The Settings
debug well is shown and hidden with the hidden class, since clearing an
inline display no longer uncovers it. Two tests that found the colour dot
by its inline style now find it by its class. Script that sets
element.style is unaffected.

Model: opus-5-5
2026-10-05 15:26:06 +02:00
clawbot a0360a7874 chore: drop an AI vendor's tool directory from .prettierignore (closes #363)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 3s
.prettierignore listed an AI vendor's tool directory, the only such
name in the tree. The directory is not tracked, so the line ignored
nothing and removing it changes no formatting result.

Model: opus-5-5
2026-10-05 14:59:16 +02:00
clawbot 9776f62f28 test: drive WaitTx's timeout and failed-lookup exits end to end (closes #315)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Two Chrome end-to-end cases send ETH and leave the wait for its receipt
running. In one, lookups answer "no receipt" until the 60-second deadline
ends the wait with the timeout message. In the other, a new fixture switch
makes every receipt lookup fail, and the sixth failure in a row ends the
wait with the message naming the unreachable network. Both check the exact
message and that Done returns to the address screen. Both wait in real
time: Playwright's clock would apply to every later test, and backdating
the stored broadcast time races the popup's own save.

Model: opus-5-5
2026-10-05 14:26:06 +02:00
clawbot 9bd607b411 test: assert the last #150 and #151 items in the Chrome suite (closes #295)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Adding a token by its contract address is checked against the address
screen's balance list. TransactionDetail opened from the token screen is
checked on the persisted navigation stack, on arrival and after Back,
which is what tells it apart from the address screen's entry point. The
token contract row's explorer link is read off the anchor, not followed,
so it needs no network fixture.

The network stub now answers symbol() and name() for the stub token,
which Add Token reads; before, both decoded as empty strings. The token
stays tracked for the rest of the run.

Model: opus-5-5
2026-10-05 13:59:18 +02:00
clawbot 5d26283cd0 test: cover every control that refuses a defective wallet (closes #254)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s
Each control that leads to a signature or to the private key now has a
test that it refuses a defective wallet before decrypting anything: Send
on the main, address and token screens, Export Private Key, and both
approval screens, as drawn and as clicked.

Send on the confirmation screen had no such check. The Send buttons
stand in front of it, but the popup reopens onto it from a saved view,
so it now refuses the same way.

The comments that said the wallet's key cannot be derived now say that
getSignerForAddress refuses it, and the walletDefects module comment
names both earlier import paths.

Model: opus-5-5
2026-10-05 12:59:15 +02:00
clawbot d0bbb3d9eb test: drive the private key export screen end to end (closes #253)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The Chrome suite now drives the private key export screen as it drives the
recovery phrase screen: the correct password shows the key, leaving by the
settings gear empties the screen, and leaving while the password is still
being checked never puts the key on it. The cases use the imported key
wallet: leaving drops the address the screen was showing, so on an HD
wallet a late decrypt fails by itself and the liveness check would go
untested. Only the phrase screen's state reader now takes the screen's
name, and serves both; the wipe assertion takes the secret, as before. A
second open in one popup session throws (#460), so the cases reopen the
popup before it.

Model: opus-5-5
2026-10-05 12:26:08 +02:00
clawbot 35125db6d1 test: drive the StateRecovery screen in both browser suites (closes #361)
e2e / e2e-chrome (push) Failing after 15s
e2e / e2e-firefox (push) Failing after 11s
check / check (push) Failing after 3h5m53s
A stored record a newer build wrote opens the popup on the recovery
screen. Export Saved Data puts that record, exactly as stored, in the
text box; a near-miss confirmation phrase erases nothing; the exact
phrase erases it and reloads into Welcome. Chrome and Firefox run the
same four cases, each under its shipped CSP.

They run before any wallet exists: with no wallet nothing saves on a
timer, so no save can write a good record over the unreadable one, and
the erase leaves the popup on Welcome for wallet creation. If any of
them fails, the last one removes the record so later tests still start
from Welcome.

Model: opus-5-5
2026-10-05 11:43:07 +02:00
clawbot eec3e23099 chore: escape every value the views write as markup, and cut symbols on code points (closes #329)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The token screen's decimals and holder count, the ETH price, every address
total and each balance row's USD value went into innerHTML unescaped, against
the rule at the top of src/popup/views/helpers.js. They are escaped now. None
could carry markup, but formatUsd() writes a value under a cent as "< $0.01".

displaySymbol() counts a symbol in code points, not UTF-16 units, so the cut
never leaves half of an emoji, which rendered as U+FFFD.

explorerLink() was already removed on next.

Model: opus-5-5
2026-10-05 10:43:06 +02:00
clawbot 0af8b09305 test: a failing e2e test leaves no fixture switch or send screen behind (closes #318)
check / check (push) Failing after 4s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s
A test that turns a fixture switch on for itself alone turns it off in a
finally, so a failure no longer reddens the tests after it. The two tests
that drive the popup's own send also return it to the address screen,
reopening the popup to leave a wait for a receipt. The lying-decimals()
test asserts that nothing was broadcast as soon as the send ends, before
waiting for the failure screen. ethCallResult() answers an override of 0
instead of falling back to the explorer's scale.

Model: opus-5-5
2026-10-05 10:09:07 +02:00
clawbot 83b169d991 fix: Chrome draws the popup in its monospace font (closes #418)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
Chrome adds a stylesheet of its own to extension pages that sets the font
on body. Tailwind 4 puts its classes in a cascade layer, and a rule outside
any layer wins over them, so font-mono lost and Chrome drew the popup in
the system font. body now carries font-mono!, which marks the class
important. Both end-to-end suites check the popup's font.

The same stylesheet also makes Chrome draw the popup's text at 12px rather
than text-sm's 14px; that is unchanged here and filed as issue 456.

Model: opus-5-5
2026-10-05 09:43:08 +02:00
clawbot 6fece80afd chore: remove dead exports and share copied view helpers (closes #168)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
AddressDetail and AddressToken defined their own isoDate() and
timeAgo(), hiding the shared pair in helpers.js, so a fix there would
not have reached them. The copies were identical and are deleted;
blockieHtml() and tokenLabel(), each defined twice, move to helpers.js.
A new test shows the history rows and the transaction detail view
write the time with the shared pair.

Deleted as never called: explorerLink(), ETHEREUM_SEPOLIA_CHAIN_ID,
getWalletValue() and getTotalValue() with their tests. Home's "Total:"
is the active address's total, as README.md already says.
addressColor() and etherscanAddressUrl() are no longer exported.

Model: opus-5-5
2026-10-05 09:09:06 +02:00
clawbot 6613a1f6bc fix: open an approval window while another one has focus (closes #290)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The background centred each approval window on the last focused window,
which could be an earlier approval window still open; headless Chrome
reports one as 1280x720, the browser refused the resulting position, and
the request failed with no window. It now centres only on a browser
window, and when the browser refuses a position it asks again without one.

In the Chrome suite a test could raise its prompt while the previous
test's window was still closing. After a passed test the runner now gives
approval windows five seconds to close and fails the test if one is still
open; after a failed test it closes them.

Model: opus-5-5
2026-10-05 08:26:06 +02:00
clawbot a207ac70bd feat: a "Max" button on the Send screen (closes #198)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s
Max fills in a token's balance, cut down to the 18 decimal places the
confirmation screen accepts, or for ETH the exact balance minus the fee
reserve the confirmation screen's balance check gates on. An ETH fee estimate
that finishes after the Send screen was left, or its address, holding,
recipient or amount changed, fills nothing in. The confirmation screen works a
max ETH amount out again from its own fee estimate and signs it with that
estimate's fee fields, so a fee that rose before signing cannot push amount
plus fee above the balance. validateTransfer() still gates every send, the
check that ETH covers a token send's fee included. Where there is nothing to
fill in, a flash message says why.

Model: opus-5-5
2026-10-05 07:43:06 +02:00
clawbot cf7ca99215 test: a token scale of zero decimals is used, not skipped (closes #325)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
resolveTokenDecimals() treats a scale of 0 from the bundled list or a
tracked token as an answer, but nothing tested it: changing either
`d !== null` check to a plain truthiness check left every test green
while a zero-decimal token fell through to the next source or to
"decimals unknown".

The approval tests now assert a scale of 0 from each source, both from
the resolver and on the approval screen's Amount line. toDecimals() was
already shared from transferAmount.js since #349.

Model: opus-5-5
2026-10-05 05:43:05 +02:00
clawbot 90a9d5597f test: the e2e suite waits for each save before it closes the popup (closes #446)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The Settings round trip switched the theme and the network and closed
the popup at once. A close before the change handler's save lands loses
the switch, and the suite then ran on Sepolia.

tests/e2e/run.js now has one helper that polls a field of the stored
record until it holds the expected value, in place of the wait that
only read viewStack. Each Settings switch and spam-filter toggle waits
for its save, the recovery-phrase reopen waits for its saved view, and
reopenPopup() waits until the view it expects to reopen on is the saved
one. README.md no longer lists #446 among the open reports of the
Chrome suite failing under load.

Model: opus-5-5
2026-10-05 05:09:04 +02:00
clawbot 6a86b726d2 fix: a change made while an earlier save is running is stored (closes #448)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 3s
saveStateOnce() took its baseline from the page's state after the write, so
a change made while the save waited on storage counted as already stored and
the save queued after it wrote nothing. A setting changed during the read was
lost, and so was a wallet added, a site revoked or an endpoint changed during
the write. The save now copies the page's fields when it starts, writes from
that copy, and keeps the copy as the baseline, so anything changed after the
copy is still a difference for the next save.

Model: opus-5-5
2026-10-05 04:43:06 +02:00
clawbot 18bdafd130 fix: open no approval window for a site-connection prompt already answered (closes #287)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s
When a site-connection prompt was decided before the toolbar popup raised
for it had loaded, that popup was torn down, chrome.action.openPopup()
rejected, and the background opened its fallback window for the answered
approval and only then removed it. In the Chrome end-to-end suite the next
test could take that window for its own prompt and lose it under its wait.
openApprovalWindow() now returns before creating a window when the approval
is no longer pending.

The blocklist test clicked its self-closing Reject with a plain click; it
now clicks it as the other site Reject does, with the click witnessed.
README.md and the e2e workflow comment no longer name this issue as what
keeps e2e-chrome from being a required check.

Model: opus-5-5
2026-10-05 04:09:07 +02:00
clawbot 8c8caafe33 harden: lost-password confirmation refuses empty input and ignores invisible characters (closes #336)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
A wallet named only with spaces compared equal to an empty field, so
typing nothing would have deleted it, and a zero-width space in a name
made the name impossible to type back.

An empty typed confirmation is now refused whatever the name is. The
characters src/shared/symbolSpoof.js already defines as painting nothing
are removed from both sides before comparing. A name that shows nothing
at all is shown on the delete screens as "Wallet N", so it can still be
typed back.

Model: opus-5-5
2026-10-05 03:26:05 +02:00
clawbot 6c885a0c05 harden: a holders_count that is not plain digits is unknown, not read in part (closes #251)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
parseHoldersCount used parseInt, which reads "1,000" as 1, "0x10" as 0 and
"1e3" as 1: a reported low count, which hides the token in the transaction
history and the send-screen token selector. It now accepts only a whole
number of zero or more, or a string of digits alone, no larger than
Number.MAX_SAFE_INTEGER, and returns null for anything else. The balance
list's holders !== null check did nothing, since null >= 1000 is already
false, and is dropped. README.md and docs/README.md say how each filter
treats an unknown count and that the token screen then leaves out its
Holders row; README.md lists src/shared/holders.js.

Model: opus-5-5
2026-10-05 02:59:15 +02:00
clawbot f86740ce69 test: the popup boot's stand-in for filterTransactions returns the real shape (closes #429)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The stand-in in tests/support/popupBoot.js returned a bare list, while the
real filterTransactions returns { transactions, newFraudContracts }. Home,
AddressDetail and AddressToken read both fields, so every test boot onto
one of them threw inside its transaction loading, logged loadHomeTxs failed
or loadTransactions failed, and never ran the rest of that code. The
stand-in now returns the real shape, and tests/persistedFieldContract.test.js
boots onto each of the three views and asserts neither message is logged.

Model: opus-5-5
2026-10-05 01:59:16 +02:00
clawbot e3790c5da4 chore: the native token's label follows the network (closes #372)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing
read it: every screen wrote ETH. The wallet's balances and the Send and
confirmation screens now use the active network's. A transaction's figures use
the network its chain id names, through nativeCurrencyByChainId(): the
approval value and fee, the wait, success and error screens, history entries,
the detail screen and the fee-limit refusal, so a site switching networks
cannot make one read as another network's coin. The "ETH" that selectedToken
and txInfo.token hold is the native token's id and is unchanged. A token
reporting any network's nativeCurrency is a spoof, and the detail screen calls
an entry a token transfer when it has a token contract.

Model: opus-5-5
2026-10-05 01:26:04 +02:00
clawbot 2b97aae04a fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already runs the check loadState()
runs at open; a save refused by it now stops the ten-second refresh, runs the
leave cleanup of the current screen, and raises the recovery screen. From then
on showView() shows nothing else in that popup, so a transaction wait or a later
save cannot take the user off it or clear an export or a typed confirmation.
That is held in memory, never as the saved current view, so a popup opened
after the record is erased elsewhere opens normally. Any other failed save
keeps the "NOT SAVED" banner. The popup test harness now honours
clearInterval().

Model: opus-5-5
2026-10-05 00:09:06 +02:00
clawbot 6127fd9432 fix: a swap deadline later than a date can hold is stated in words (closes #437)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
decode() rendered the Deadline line with toISOString(), which throws on a
date past 275760-09-13, the last a JavaScript date can hold. A later
deadline, such as the uint256 maximum, therefore left the whole swap
undecoded, with nothing saying why. That line now reads
"After 275760-09-13 00:00:00 (no deadline in practice)".

Model: opus-5-5
2026-10-04 23:43:06 +02:00
clawbot f4a51e1679 fix: the swap decoder reads a V2 already-paid zero and a zero balance check as the router does (closes #415)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
A V2 exact-in amountIn of zero is the router's ALREADY_PAID marker: an
earlier step sent the tokens to the pair and the swap spends all of them.
Amount showed 0.0000 for it; it now reads "Whatever an earlier step sent
to the pair (V2 already paid)", in the style of the V4 open delta line.

A BALANCE_CHECK_ERC20 passes whenever the balance is at least minBalance,
so a zero one guarantees nothing. It now sets the output side only when
that side holds no minimum at the point the check is reached; a nonzero
one sets the output side as before.

README's Display Consistency text and TODO.md are updated to match.

Model: opus-5-5
2026-10-04 23:09:05 +02:00
clawbot 375998beaf harden: show a personal message's hex and its text in byte order, hidden characters marked (closes #403)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The signature screen showed only the text a personal message decodes
to, with bidirectional, right-to-left and zero-width characters acting
on it, so a site could make the message read differently from the
bytes that are signed, and a message that was not hex was decoded into
NUL characters. The screen now shows the hex as "Raw data" alongside
the text, lays the text out left to right in byte order, and shows each
control character, line and paragraph separator, and character that
paints nothing (the set src/shared/symbolSpoof.js already strips) as a
U+XXXX mark. A message is hex when getBytes, which signing uses, reads
it; one that is not cannot be signed, so it is shown as plain text with
"Sign" disabled.

Model: opus-5-5
2026-10-04 22:09:07 +02:00
clawbot 3b713809c8 harden: a token scale above 80 decimal places is refused as unknown (closes #350)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
toDecimals() accepted any uint8 scale, but formatUnits() and parseUnits()
refuse more than 80 decimal places. A token reporting 81 to 255 made the
formatter throw, and the catch in the swap decoder and in the ERC-20 decoder
turned that into an undecoded approval screen with nothing saying why.

MAX_DECIMALS is now 80, the formatter's own limit, so such a scale is
treated exactly like an unknown one: both approval paths show the base-unit
amount with the scale stated as unknown. The balance list, the history list
and the Send screen use the same check.

Model: opus-5-5
2026-10-04 21:43:11 +02:00
clawbot 8ac2c87c2c harden: debug mode logs only a request's origin and JSON-RPC method (closes #410)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 2s
With debug mode on, debugFetch logged every request's full URL and body,
so an RPC endpoint with an API key in its path or query string printed
that key to the console on every request. It now logs the HTTP method,
the URL's origin and, for a JSON-RPC body, the method name. The balance
refresh and token lookup log the RPC endpoint by its origin too. Failed
RPC calls print ethers' short message, since its full message for an
HTTP error carries the request URL. A failed endpoint check in settings
prints the endpoint's origin, since fetch's error for a URL with a user
name and password carries the whole URL. The README's DEBUG Mode Policy
says what debug mode logs.

Model: opus-5-5
2026-10-04 21:09:04 +02:00
clawbot d1751beb32 harden: one connection and one signature prompt per site at a time (closes #405)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Each eth_requestAccounts or personal_sign call opened another approval
window, so a page calling in a loop could cover the screen with identical
prompts. While a site's connection or signature prompt is unanswered, a
further request of that kind from the same site is now refused with
EIP-1193 -32002 and opens no window; all signing methods count as one
kind. A connection prompt whose toolbar popup closed before it connected,
and which the toolbar popup no longer opens, is shown again by the site's
next request instead of refusing the site until the address changes.

Model: opus-5-5
2026-10-04 19:43:11 +02:00
clawbot de3f7a9a11 harden: ignore a nonce the page supplies with eth_sendTransaction (closes #404)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
A site could fix the nonce of the transaction the user was asked to
sign: the same nonce as a pending transaction, at a higher fee,
replaces it, and a nonce above the account's next one leaves the new
transaction stuck behind a gap. `nonce` is no longer one of the fields
taken from the request, so the transaction always gets the account's
next nonce from the network, and that is the nonce the approval screen
shows and the popup signs.

Model: opus-5-5
2026-10-04 18:43:04 +02:00
124 changed files with 9712 additions and 1980 deletions
+77 -7
View File
@@ -1,7 +1,77 @@
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for # .dockerignore does NOT use .gitignore semantics. Docker matches with
# build-info stamping and the Dockerfile runs `make build`, so excluding it # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# would make every built extension report commitHash "unknown". # `/` and an unprefixed pattern is anchored at the context root. Every
node_modules # depth-independent pattern therefore needs `**/`, or `config/.env` and
.DS_Store # `certs/server.key` still ship while this file reads as solved. Only
dist # genuinely root-anchored entries go unprefixed. Never transplant these
release # into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repo's host-built artifacts: make build writes dist/ and make package
# writes release/. The image builds its own.
/dist
/release
+17 -10
View File
@@ -2,11 +2,11 @@ name: e2e
on: [push] on: [push]
# The browser end-to-end suites, one job per browser, deliberately kept out # 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 # of the check workflow: REPO_POLICIES.md caps make test at 60 seconds and
# script/cibuild is a plain `docker build .` whose Dockerfile runs # script/cibuild runs script/check, so folding a browser suite into either
# make check, so folding a browser suite into either would blow that cap # would blow that cap and slow the local fast path. Before this workflow
# and slow the local fast path. Before this workflow every browser-level # every browser-level guarantee in this repo held only when a human
# guarantee in this repo held only when a human remembered to run it. # remembered to run it.
# #
# One job per browser rather than two steps in one job, so a Chrome failure # One job per browser rather than two steps in one job, so a Chrome failure
# does not hide the Firefox result. # does not hide the Firefox result.
@@ -22,11 +22,10 @@ on: [push]
# These jobs REPORT, they do not gate. Whether a check blocks a merge is # 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 # 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 # 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 # block. Making e2e-chrome a required check is blocked while reports of the
# flake in the dApp signing wait -- two of six runs of unmutated code on a # Chrome suite failing under load are still open; the "In CI" section of
# loaded machine -- tracked as # README.md names them. A gate that fails at random teaches people to merge
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at # past red.
# random teaches people to merge past red.
# #
# Nothing here may pass vacuously. There is no continue-on-error and no # 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 # `|| true`. Both scripts exit non-zero when docker is missing, when the
@@ -36,6 +35,10 @@ on: [push]
jobs: jobs:
e2e-chrome: e2e-chrome:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Chrome
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -43,6 +46,10 @@ jobs:
e2e-firefox: e2e-firefox:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Firefox
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
timeout-minutes: 15
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+31 -5
View File
@@ -11,14 +11,40 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node # Node
node_modules/ node_modules/
# Environment / secrets # Secrets. Unanchored like every entry above, so each matches at every
.env # depth. Matching is case-sensitive on Linux, so names use character
.env.* # ranges rather than a lowercase form that misses `Server.Key`.
*.pem
*.key # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# Build output # Build output
dist/ dist/
-3
View File
@@ -1,5 +1,2 @@
node_modules/ node_modules/
yarn.lock yarn.lock
dist/
release/
.claude/
+67 -28
View File
@@ -1,41 +1,80 @@
# Lint phase: ESLint, prettier --check, and script/check-censored. The tools
# are invoked directly rather than through `make lint` or `script/lint`, which
# are themselves a docker build and would recurse into a daemon that does not
# exist in a build step.
#
# node:22-slim (22.x LTS), 2026-02-24 # node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS lint
WORKDIR /app 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. In here the same suite
# starts on a cold jest cache and shares the runner with the rest of the build,
# so 30s is too tight — 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 script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests RUN yarn run lint
# and the build. This is also the stage script/lint builds from a host, which RUN script/check-censored
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint # Test phase, same shape and for the same reason: the jest suite (its worker
RUN make lint # cap is in package.json), rerun verbose on failure, then
# script/test-verify-build.
#
# node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN timeout 90 yarn run test || \
{ echo "--- Rerunning with --verbose for details ---"; \
timeout 90 yarn run test:verbose; exit 1; }
RUN script/test-verify-build
# Development environment with the extension built, and the last stage: a
# plain `docker build .` names no target and so builds this one. Nothing is
# wanted from the two phases above; the copies are what make BuildKit build
# them first, so this image cannot be produced unless lint and test passed. A
# stage appended after this one would drop all three out of a plain build.
#
# node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
WORKDIR /app
# 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 COPY --from=lint /app/package.json /dev/null
COPY --from=test /app/package.json /dev/null
RUN make check # script/bootstrap installs all prerequisites, git included. Manifests are
RUN make build # copied first so that layer stays cached until dependencies change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /app
COPY . .
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With .git
# present, a version that is still empty, dev or unknown fails the build: git
# is missing or could not read the checkout, and build.js, which stamps the
# extension with the commit it was built from, would stamp "unknown".
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
make build
# A LABEL cannot run git, so it carries the build argument alone; a plain
# `docker build .` leaves it empty.
LABEL org.opencontainers.image.version="${VERSION}"
+16 -1
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev .PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons clean dev
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -104,9 +104,24 @@ build-debug:
vendor-blocklist: vendor-blocklist:
@script/vendor-blocklist @script/vendor-blocklist
# Redraw the toolbar icons in icons/ from script/lib/icons.js, at every size
# manifest/chrome.json declares, leaving alone a file that already holds the
# drawn image. Commit the result with the drawing: tests/icons.test.js fails
# while the two disagree.
icons:
@node script/lib/icons.js
clean: clean:
@rm -rf dist/ release/ @rm -rf dist/ release/
# Run the build make build runs, without the checks that follow it, then run it
# again after every change to a file under src/, manifest/ or icons/, until
# interrupted. A failed build is reported, may leave dist/ partly written, and
# watching carries on. It writes no build receipt, so nothing can verify what it
# leaves in dist/: anything handed on comes from make build. A release build
# unless AUTISTMASK_DEBUG=1 is exported. A change anywhere else, package.json
# and build.js included, starts no build, and a directory created while it runs
# is not watched; restart it after either.
dev: dev:
@echo "Building in watch mode..." @echo "Building in watch mode..."
@yarn run build --watch 2>&1 @yarn run build --watch 2>&1
+428 -187
View File
File diff suppressed because it is too large Load Diff
+357 -86
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-07-06 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,17 +60,28 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the 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 repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
scripts are our own extensions to the standard: `script/check` runs with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is `script/bootstrap` first**, because the workflow checks out the repo and runs
what the git pre-commit hook runs, and it calls `script/check`; nothing else, while `script/fmt-check` runs the formatter on the host: on a
`script/install-precommit` installs the git pre-commit hook (the `make hooks` pristine checkout with nothing installed the run dies there, after the
target shims to it); and `script/projectname` (literally that filename) simply containerised gates have passed. **The bootstrap alone is not enough**:
outputs the project's name. Scripts that need the name call `script/bootstrap` installs node and yarn under nvm and leaves neither on the
`script/projectname` — e.g. `script/docker` assembles its image tag from it — `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
so those scripts stay byte-identical across all repos. Repo-type-specific entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in source nvm for the pinned node version before invoking it, exactly as
`script/precommit`, not in the hook itself. Model scripts are at `script/bootstrap`'s own install step does. A runner carrying nothing but
docker and git then gets through `script/check`. 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 `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -89,87 +100,198 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`, and it carries the repo's gates: a
as a build step so the build fails if the branch is not green. For non-server `lint` phase and a `test` phase, with the final stage depending on both so the
repos, the Dockerfile should bring up a development environment and run image cannot be built unless they pass. For non-server repos the final stage
`make check`. For server repos, `make check` should run as an early build brings up a development environment; for server repos it is the runtime image.
stage before the final image is assembled. Dockerfiles install development The gate phases and the build stage start from their pinned base images and
prerequisites by running `script/bootstrap` rather than duplicating installs install what those images lack either inline, as the canonical Go `Dockerfile`
inline; COPY `script/` and the dependency manifests (`package.json` + below does for `git`, or by running `script/bootstrap`, as the `prompts`
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap repo's own `Dockerfile` does for its yarn packages. The development
layer stays cached until dependencies change. environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
repos use a multistage build where linting runs in an independent stage based no separate lint file. `script/lint` and `script/test` each build one phase
on the `golangci/golangci-lint` image (pinned by hash). This stage runs and nothing else:
`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: ```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
```dockerfile ```dockerfile
# Lint stage — fast feedback on formatting and lint issues # Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make fmt-check RUN golangci-lint run --config .golangci.yml ./...
RUN make lint
# Build stage # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.x-alpine, YYYY-MM-DD # image ships and the alpine one does not.
FROM golang@sha256:... AS builder # golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
ARG VERSION=dev # Build stage. Nothing is wanted from either phase above; the copies
RUN CGO_ENABLED=0 go build -trimpath \ # are what make BuildKit build them first, so this stage cannot run
-ldflags="-s -w -X main.Version=${VERSION}" \ # unless lint and test passed.
-o /app ./cmd/app/ # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Runtime stage # The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it - The lint phase uses the `golangci/golangci-lint` image directly (it has
includes both Go and the linter), so there is no need to install the both Go and the linter), so nothing needs installing.
linter separately. - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates purpose is the ordering edge. BuildKit runs stages in parallel by default,
a stage dependency. BuildKit runs stages in parallel by default; without and a stage nothing depends on is not built at all, so without these two
this line, the build stage would not wait for lint to finish and a lint lines a red gate would not fail the build.
failure might not fail the overall build. - Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- If the project uses `//go:embed` directives that reference build artifacts - 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 (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `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 - If the project requires CGO or system libraries for linting, install them
fail fast. in the lint phase. The `golangci/golangci-lint` image is Debian-based and
- If the project requires CGO or system libraries for linting (e.g. has no `apk`, so install with `apt-get` under the Debian package name
`vips-dev`), install them in the lint stage with `apk add`. (`libvips-dev`, where alpine says `vips-dev`), and delete the package
- The build stage runs `make test` after compilation setup. Tests run in the lists in the same `RUN`, so the layer does not keep them:
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies. ```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the runs `script/cibuild` on push, and checks out the repo as its only other step.
Dockerfile already runs `make check`, a successful build implies all checks That script bootstraps, runs the gate phases, and then builds the image, so a
pass. successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -189,14 +311,21 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op. `make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 60 seconds. That is the hard cap, and a
Makefile. suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
hard cap so that it catches a genuinely hung test rather than a merely slow
one.
- **`make test` should use the conditional verbose rerun pattern.** Run tests - **The test command should use the conditional verbose rerun pattern.** Run
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to tests without `-v` (verbose) first. If tests fail, automatically rerun with
show full output. This keeps CI logs and `docker build` output clean on `-v` to show full output. This keeps CI logs and `docker build` output clean
success (just package/suite summaries) while providing full diagnostic detail on success (just package/suite summaries) while providing full diagnostic
on failure (every test case, every assertion). The general shell pattern: detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
```makefile ```makefile
test: test:
@@ -209,11 +338,26 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -timeout 30s -race -cover ./... || \ @go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; } go test -count=1 -timeout 90s -race -v ./...; exit 1; }
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
```makefile ```makefile
@@ -239,10 +383,84 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
Fetch the standard `.gitignore` from language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
a new repo. setting up a new repo. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root, while `config/.env`
and `certs/server.key` still reach the context and can land in an image layer
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
also catches something the build needs, re-include it with a negation
(`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own artifacts.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo and another session's unreviewed work can
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` states in its own comment, since consuming repos receive the
file and not the tracker: the directory is created in the agent's working
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
# trip `set -e`, so the inline form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
than failing, and the `[ -n "$version" ]` line is the single place the
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
size is not a substitute: a nested secret is a few bytes, and BuildKit
transfers only the delta from the previous build.
- **No build artifacts in version control.** Code-derived data (compiled - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
@@ -258,9 +476,56 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a - Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - `.golangci.yml` is standardized. The vendored copy in a consuming repo must
manually by the user. Fetch from _NEVER_ be modified by an agent: fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
byte-identical, so that no repo can quietly loosen its own linting. Linter
configuration changes are made to the canonical copy in the `prompts` repo and
reach consuming repos by re-vendoring; an agent may open a PR against
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
`PATH` only, so on an already-provisioned machine the pin is inert and a
version bump is a silent no-op — while the Dockerfile, installing into a clean
image, gets the pinned version, so a local `make check` and `make docker` can
disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -374,12 +639,14 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
language-specific config). Everything else goes in a subdirectory. Canonical and language-specific config). Everything else goes in a subdirectory.
subdirectory names: Canonical subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `configs/` — configuration templates and examples - `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform) - `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root) - `docs/` — documentation and markdown (README.md stays in root)
@@ -406,3 +673,7 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+562 -14
View File
@@ -23,28 +23,570 @@
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The 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 milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified [#388](https://git.eeqj.de/sneak/AutistMask/pulls/388). `make build` produces
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces `dist/chrome/` and `dist/firefox/` with `DEBUG` compiled off, and checks them
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to against the build's own receipt to hold exactly the regular files and symlinks
hold exactly the regular files and symlinks that build emitted, with `DEBUG` that build emitted.
compiled off.
The backlog lives on the The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present. authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome, Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does `make test-e2e-firefox` for Firefox) sit alongside `make check`, which runs the
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both tests, static analysis and the formatting check, and `.gitea/workflows/e2e.yml`
of them on every push. runs both of them on every push.
# Next Step # Next Step
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC Cut 1.0.0 once the
input validation) before any 1.0rc tag. Individual filed issues are parts of it, [1.0.0 milestone](https://git.eeqj.de/sneak/AutistMask/milestone/6) is empty,
but the review is broader than any of them. then continue tagging as milestones land.
# Completed Steps # Completed Steps
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
check that every swept field is driven both truthy and falsy counts only
`hostile` and `falsy` values, which the sweep drives onto every restorable
view, and no longer a `hostileRestore` value, which reaches only the views its
entry names; the stale index 5 moves into `hostile` for `selectedWallet` and
`selectedAddress`, as the one value of either still truthy after the floor.
Each `hostileRestore` entry declares whether its boot lands on its view or
falls back to Home, and is held to it. The limits that remain, fields that
share a slot on one boot and anything no stored record reaches by itself, are
restated as built in the file's header and the README, which now also say
which boots are held to where the popup lands and that a healthy profile is
booted onto every restorable view.
- 2026-10-07: Every password error in the popup is shown the same way
([#493](https://git.eeqj.de/sneak/AutistMask/issues/493)): with `showError()`
and `hideError()` in a fixed-height error line below the password field, as
the send confirmation and approval screens already did. The add wallet screen
showed a missing, short or mismatched password in the flash line, and the
private key export, recovery phrase and delete wallet screens each had a line
of their own above the field. On the add wallet screen the line sits beside
the Import button, so the button stays where it was at 360x600. Each line
clears when the screen is shown again and when the password is tried again.
The add wallet screen's other messages, such as an invalid recovery phrase, a
duplicate wallet and the address scan, stay in the flash line.
`tests/passwordErrorLines.test.js` drives all four screens in the popup.
- 2026-10-07: Pre-1.0 security review of the extension
([#383](https://git.eeqj.de/sneak/AutistMask/issues/383)), reading the tree at
`99292b9` for key handling, the DEBUG mode policy, and what the background
accepts from pages, the configured RPC endpoint and the explorer, with what
the approval screens show from it; the site permission model and storage were
read as well. Its summary on that issue lists ten findings, each filed as its
own issue, and all ten are fixed on `next`; one,
[#399](https://git.eeqj.de/sneak/AutistMask/issues/399), put funds at risk.
Three decisions it raised are still open with the owner: the Argon2id cost for
the vault key ([#401](https://git.eeqj.de/sneak/AutistMask/issues/401)), a
connected site switching the network with no prompt
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)), and `eth_sign`
signing as a personal message
([#409](https://git.eeqj.de/sneak/AutistMask/issues/409)). Not covered: the
end-to-end suites were not run, the bundled phishing blocklist and token list
were not checked entry by entry, `ethers` and `libsodium-wrappers-sumo` were
taken as audited, and nothing was tried against a real network with real funds
([#385](https://git.eeqj.de/sneak/AutistMask/issues/385)). The planned
independent second check of each finding did not run; the findings rest on the
reviewer's own reading of the code.
- 2026-10-07: Stale branches pruned from `origin`
([#167](https://git.eeqj.de/sneak/AutistMask/issues/167)). The issue
classifies each branch it lists, with the evidence. The eighteen still on
`origin` that it classifies as landed, or superseded by merged pull requests,
were deleted. `feat/message-signing` and `fix/59-transaction-view-ui-policies`
had been deleted on 2026-09-09; both landed and stay deleted. Four are kept
because their work is not in `next`: `fix/consistent-error-display`,
`fix/87-consistent-error-display` and `fix/87-consistent-error-display-v2`,
the change for [#87](https://git.eeqj.de/sneak/AutistMask/issues/87) that
never landed, as reference for
[#493](https://git.eeqj.de/sneak/AutistMask/issues/493); and
`chore/token-list-enrichment`, deleted on 2026-09-09 and re-created at
`f7a2437`, whose `scripts/` tooling waits on
[#495](https://git.eeqj.de/sneak/AutistMask/issues/495).
- 2026-10-07: The README says that the wallet never clears the clipboard after
the private key or the recovery phrase is copied, and why
([#492](https://git.eeqj.de/sneak/AutistMask/issues/492)): the clipboard is
the user's, clearing it would go against what they expect, and it could
destroy something else they copied since. It is under ExportPrivKey, with a
line under ShowRecoveryPhrase, and names the warning each password screen
actually shows, which says nothing about the clipboard.
- 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught
extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its
one entry, Firefox reporting a popup promise that settled after the page
unloaded, had lost its cause with
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275); the entry and the
code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`,
and so is the README paragraph that described it.
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
own `zlib`, no new dependency, leaving alone a file that already holds the
drawn image. `tests/icons.test.js` requires each committed file to hold
exactly that image, the same IHDR and every pixel. The compressed bytes are
not compared, because node's bundled zlib does not compress them as the stock
zlib that made the committed files did. `build.js` now copies each manifest
from the same path `copyIcons()` reads its icons from.
- 2026-10-07: The README's end-to-end limits now match what the two browser
suites do to the extension
([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close`
override the issue named went with
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), so it needs no entry.
Added: both suites load the popup in a tab rather than from the toolbar;
Chrome waits 1.5 seconds before each site-connection request, records what
approval windows send and click, grants clipboard permission, writes text
straight into the page in two layout tests, and forces the leave during a
decrypt; Firefox forces the site-connection prompt into a window. Corrected:
Firefox's one tolerated error, whose cause that fix removed (the entry goes in
[#487](https://git.eeqj.de/sneak/AutistMask/issues/487)), the phishing
blocklist the extension no longer fetches, and two stale figures.
- 2026-10-07: Back from Settings no longer shows Settings again after the
settings gear was pressed on the recovery phrase or a delete wallet screen
opened from Settings, with or without a reopen in between
([#481](https://git.eeqj.de/sneak/AutistMask/issues/481)). Those screens take
themselves off the Back stack when left, which leaves Settings under Settings;
`goBack()` now skips an entry for the screen already showing.
`tests/showPhrase.test.js`, `tests/deleteWalletLostPassword.test.js` and
`tests/backNavigation.test.js` drive each path.
- 2026-10-07: `script/discard-dist-on-failure` returns the failed step's own
exit status even when it cannot write its message, to a closed stderr or to a
pipe nobody reads any more
([#342](https://git.eeqj.de/sneak/AutistMask/issues/342)); it used to return 2
or 141 instead. An interrupt while a step runs now removes nothing and says
nothing whichever shell `/bin/sh` is, even when the step catches it and exits
with a status of its own, as `script/check-censored` does: the wrapper exits
with 130 once the step has ended. Under bash such a step used to have `dist/`
removed. A failed `check-censored --require-dist` still removes `dist/` like
any other step, because a `dist/` not cleared of the name `RULES.md` bars must
not ship. The header states both. `script/test-verify-build` runs the wrapper
with stderr closed and with stderr a pipe nobody reads, and interrupts it
under dash and under bash.
- 2026-10-06: Back from Settings no longer lands on the delete wallet or
lost-password screen after either was left by the settings gear
([#480](https://git.eeqj.de/sneak/AutistMask/issues/480)), the defect
[#461](https://git.eeqj.de/sneak/AutistMask/issues/461) fixed for the two
secret screens. Leaving drops the screen's wallet selection, so its leave
handler now also takes it off the Back stack, as a reopened popup already
does. `tests/deleteWalletLostPassword.test.js` drives the gear and then Back
on both screens.
- 2026-10-06: `script/bootstrap` no longer reports success while node cannot
find a package listed in `dependencies` or `devDependencies` of `package.json`
([#263](https://git.eeqj.de/sneak/AutistMask/issues/263)). After the install
it asks node for each one's `package.json`, and fails naming the missing
package and the fix. yarn skips the install whenever
`node_modules/.yarn-integrity` matches `yarn.lock`, so a package deleted from
`node_modules` stayed deleted while bootstrap said it was complete. The
fresh-clone failure the issue reports did not reproduce.
- 2026-10-06: Back from Settings no longer lands on the private key export or
recovery phrase screen after either was left by the settings gear
([#461](https://git.eeqj.de/sneak/AutistMask/issues/461)). Leaving drops the
screen's selection, so its leave handler now also takes it off the Back stack,
as a reopened popup already does. `tests/exportPrivkey.test.js` and
`tests/showPhrase.test.js` drive the gear and then Back, and
`leavePrivkeyScreen()` in `tests/e2e/run.js` expects the address screen after
Settings.
- 2026-10-06: A token symbol or name read off a contract is no longer stored cut
between the two halves of an emoji
([#458](https://git.eeqj.de/sneak/AutistMask/issues/458)). `lookupTokenInfo()`
cut both by UTF-16 units; it now counts code points, as `displaySymbol()` has
since [#329](https://git.eeqj.de/sneak/AutistMask/issues/329).
`tests/tokenLookupTruncation.test.js` looks up a token whose symbol and name
are made of emoji outside the Basic Multilingual Plane. A symbol already
stored broken is not repaired.
- 2026-10-06: Reloading or closing the popup no longer logs a request it cancels
as a failure in the transaction lists and ENS name lookups on the address and
token screens, the address scan after a wallet is created, the endpoint checks
in Settings, the wait screen's receipt check, the Send screen's Max fee
estimate, or the token lookup on the two add-token screens
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)). Each checks the
signal the popup aborts on `pagehide`
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)) before reporting a
failure; a real failure is still logged. `scanForAddresses()`,
`resolveEnsNames()` and `lookupTokenInfo()` take the signal. The e2e suite
reloads the popup on the address screen with Blockscout held, and during the
address scan with that scan held; jest tests cover each of these with the
popup open and closed. Left out: the transaction detail screen and the
confirmation screen (its fee estimate and its recipient checks), because they
discard the popup context that carries the signal.
- 2026-10-06: The private key export screen opens again in the same popup
session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)). `show()`
found the address line through the element inside it, which its own rendering
replaced, so the second open threw before it navigated. The line now carries
the `export-privkey-address` id itself. `tests/exportPrivkey.test.js` opens
the screen twice, its DOM stub now takes an element out of the document when
its parent's contents are replaced, and the `#253` e2e case no longer reopens
the popup before its second open.
- 2026-10-06: The canonical files are re-vendored from `sneak/prompts` at
`dd4027b` ([#472](https://git.eeqj.de/sneak/AutistMask/issues/472)). The
`Dockerfile` has separate `lint` and `test` phases, and its last stage depends
on both before it runs `make build`. `script/lint` and `script/test` each
build one phase, uncached. `script/check-censored` runs in the `lint` phase
and `script/test-verify-build` in the `test` phase. `script/check` runs the
two phases and `script/fmt-check`. `script/cibuild` bootstraps, runs
`script/check`, then builds the image uncached. Given no `VERSION` build
argument, as in a plain `docker build .`, the image build takes one from
`git describe` on the `.git` in the build context, which `.dockerignore` now
sends without its `config`. The vendored `check.yml` has no `timeout-minutes`,
so the `check` job's cap is gone.
- 2026-10-06: Reloading or closing the popup mid-refresh no longer logs the
requests that cancels as failures
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)). Chrome cancels a
closing page's open requests just after `pagehide`, and in the page a
cancelled `fetch()` fails with the same "Failed to fetch" as a server that
cannot be reached, so the home screen's transaction list and the balance
refresh logged an error for each, and the e2e suite failed on them. The popup
now aborts an `AbortController` on `pagehide`, and those two check its signal
before reporting a failure. New e2e tests reload the popup with Blockscout
held and require nothing logged, and fail the transaction list for real and
require the failure reported; `tests/balanceRefreshCancelled.test.js` covers
the balance refresh. The address and token screens and the address scan after
a new wallet still log a cancelled request
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)).
- 2026-10-05: `make build` no longer prints the Node `DEP0205`
`module.register()` deprecation warning
([#355](https://git.eeqj.de/sneak/AutistMask/issues/355)). The call came from
`@tailwindcss/node`, which the Tailwind CLI loads; `tailwindcss` and
`@tailwindcss/cli` are now pinned at 4.3.1, the first release that uses
`module.registerHooks()` where Node has it. The compiled CSS computes to the
same values; it drops the unused `.start` and `.end` rules and writes
`calc(var(--spacing) * 1)` as `var(--spacing)` and `calc(var(--spacing) * 0)`
as `0`.
- 2026-10-05: `make dev` watches
([#332](https://git.eeqj.de/sneak/AutistMask/issues/332)). It used to pass
`--watch` to a `build.js` that read no arguments, so it built once and exited.
`build.js --watch` now builds, then builds again after every change to a file
under `src/`, `manifest/` or `icons/`; any other argument fails. It watches
each directory rather than using Node's recursive watch, which on Linux stops
seeing a file an editor saves by renaming a new copy over it. It writes no
build receipt, so nothing can verify what it builds; `make build` remains the
way to produce a `dist/` to hand on. `tests/buildWatch.test.js` covers the
watch loop against a temp directory.
- 2026-10-05: Every CI job has a `timeout-minutes` cap
([#294](https://git.eeqj.de/sneak/AutistMask/issues/294)): `check` 10 minutes,
`e2e-firefox` 15 and `e2e-chrome` 20, each over two and a half times the job's
slowest cold-cache run. A hung build or browser now ends its job instead of
holding the shared runner for hours.
- 2026-10-05: `PROXY_METHODS` in `src/background/index.js` no longer lists
`eth_chainId` and `net_version`
([#326](https://git.eeqj.de/sneak/AutistMask/issues/326)). `handleRpc` answers
both itself before its proxy branch, so the list named two methods that are
never sent to the RPC endpoint. No other entry is answered earlier.
`tests/proxyMethods.test.js` sends every listed method from a page and fails
on any that does not reach the RPC endpoint.
- 2026-10-05: The popup's Content Security Policy no longer allows inline style
([#328](https://git.eeqj.de/sneak/AutistMask/issues/328)): `style-src` is
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
`style="..."` attributes in `src/popup/index.html` and in the markup the view
helpers build are now Tailwind classes, each computing to the value it
replaced. The 16 address dot colours are written out as whole classes, because
Tailwind builds only the classes it finds in the source. The Settings debug
well is shown and hidden with the `hidden` class, since clearing an inline
`display` no longer uncovers it. Script that sets `element.style` is
unaffected.
- 2026-10-05: `.prettierignore` no longer lists an AI vendor's tool directory
([#363](https://git.eeqj.de/sneak/AutistMask/issues/363)). The directory is
not tracked, so the line ignored nothing.
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
transaction's receipt ends on the error screen
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
find no receipt 60 seconds after the broadcast end it with the timeout
message, and six lookups that fail in a row end it with the message naming the
unreachable network. Done then returns to the address screen. Both cases wait
in real time, about a minute each. Playwright's clock would apply to every
later test in the run and cannot be removed, and moving the stored broadcast
time back can be undone by the save the popup makes every ten seconds.
- 2026-10-05: The Chrome end-to-end suite covers the last of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) items
([#295](https://git.eeqj.de/sneak/AutistMask/issues/295)): a token added on
Add Token by its contract address is listed on the address screen;
TransactionDetail opened from the token screen leaves that screen on the
persisted navigation stack, and Back returns to it; and the token contract row
links to the explorer's token page, read off the link rather than followed.
The network stub answers `symbol()` and `name()` for the stub token, which
adding it reads.
- 2026-10-05: Each control that leads to a signature or to the private key has a
test that it refuses a defective wallet before asking for a password
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main,
address and token screens, Export Private Key, and both approval screens, as
drawn and as clicked. Send on the confirmation screen refuses it too now,
because the popup reopens onto that screen from a saved view. The comments
that said the wallet's key cannot be derived now say that
`getSignerForAddress` refuses it, and the module comment in
`src/shared/walletDefects.js` names both earlier import paths.
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
as it drives the recovery phrase screen
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
password shows the key, leaving by the settings gear empties the screen, and
leaving while the password is still being checked never puts the key on it.
The cases use the imported key wallet rather than the HD one. Leaving drops
the address the screen was showing, and an HD wallet's key cannot be derived
without it, so on an HD wallet a late decrypt fails by itself and would never
exercise the check that discards it. The screen cannot yet be opened twice in
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
so the cases reopen the popup before the second open.
- 2026-10-05: The StateRecovery screen is driven in a real browser under the
shipped CSP, in both end-to-end suites
([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record
this build cannot read opens the popup on it; its export text box holds the
record exactly as stored; a near-miss confirmation phrase erases nothing; and
the exact phrase erases the record and reloads into Welcome. The cases run
before any wallet exists: with no wallet nothing saves on a timer, so no save
can write a good record over the unreadable one, and the erase leaves the
popup on Welcome for wallet creation.
- 2026-10-05: Escaping in the popup's views follows its own rule with no
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
decimals and holder count on a token's screen, and every USD figure (the ETH
price, each total and each balance row's value), went into `innerHTML`
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
code points rather than UTF-16 units, so a cut never splits an emoji into a
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
test that turns a fixture switch on for itself alone (a held or failing gas
estimate, a seeded native transfer or receipt, a token's lying `decimals()` or
markup symbol) turns it off again in a `finally`, and the two tests that drive
the popup's own send end on the address screen whether they pass or not,
reopening the popup to leave a wait for a receipt. The lying-`decimals()` test
checks that nothing was broadcast as soon as the send ends, before it waits
for the failure screen, so a broadcast fails it in seconds rather than after a
60-second wait. The fixture's `decimals()` override tells 0 from no override,
so a token with no decimal places can be fixtured.
- 2026-10-05: Chrome draws the popup in its monospace font
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does.
Chrome adds a stylesheet of its own to extension pages that sets the font on
`body`, and it beat Tailwind's `font-mono`: Tailwind 4 puts its classes in a
cascade layer, and a rule outside any layer wins over them. `body` now carries
`font-mono!`, which marks the class important. Both end-to-end suites check
the popup's font. The same stylesheet also makes Chrome draw the popup's text
at 12px rather than the 14px `text-sm` asks for; that is unchanged, and filed
as [#456](https://git.eeqj.de/sneak/AutistMask/issues/456).
- 2026-10-05: Dead code removed and copied view helpers shared
([#168](https://git.eeqj.de/sneak/AutistMask/issues/168)). AddressDetail and
AddressToken each defined their own `isoDate()` and `timeAgo()` in place of
the ones in `src/popup/views/helpers.js`, so a fix to the shared pair would
not have reached them. The copies were identical; every screen now uses the
shared pair. `blockieHtml()` and `tokenLabel()`, each defined twice, live in
`helpers.js` too. Removed as never called: `explorerLink()` (the views build
explorer links with `explorerUrl()`), `ETHEREUM_SEPOLIA_CHAIN_ID` (the chain
id lives in `src/shared/networks.js`), and `getWalletValue()` and
`getTotalValue()`: Home's "Total:" is the active address's total, as
`README.md` says. `addressColor()` and `etherscanAddressUrl()` are no longer
exported. Nothing the user sees changed.
- 2026-10-05: A prompt raised while another approval window has focus opens a
window of its own ([#290](https://git.eeqj.de/sneak/AutistMask/issues/290)).
The background centred each approval window on the last focused window, which
could be an earlier approval window still open; headless Chrome reports one as
1280x720, so the new window came out where the browser refused to create it,
and the request failed with no window at all. It now centres only on a browser
window, and when the browser refuses the position it asks again without one
and lets the browser place the window. In the Chrome end-to-end suite a test
could raise its prompt while the previous test's window was still closing, and
then either hit that refusal or take the closing window for its own. After a
test that passed, the runner now waits a few seconds for approval windows to
close and fails the test if one is still open; after a test that failed, it
closes them.
- 2026-10-05: The Send screen has a "Max" button
([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH
address took guessing an amount and being refused by the confirmation screen's
balance check. Max fills in a token's whole balance, cut to the 18 decimal
places the confirmation screen accepts, or for ETH the exact balance minus the
fee reserve that check gates on, never the four-decimal balance shown; a fee
estimate that finishes after the Send screen was left, or its address, holding
or recipient changed, fills nothing in. The confirmation screen works a max
ETH amount out again from its own fee estimate and signs it with that
estimate's fee fields: fetched again at signing, a fee that had risen since
would leave amount plus fee above the balance, and the node would refuse the
send. A token's maximum is still refused when ETH cannot pay the fee. Where
there is nothing to fill in, a flash message says why.
- 2026-10-05: A token scale of zero decimals is tested
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
from a tracked token, but no test said so: turning either of its `d !== null`
checks into a plain truthiness check left every test green while a
zero-decimal token fell through to the next source, or to "decimals unknown".
The approval tests now assert a scale of 0 from each of those two sources,
both where it is resolved and on the approval screen's Amount line. The second
half of the issue, one shared `toDecimals()`, had already landed with
[#349](https://git.eeqj.de/sneak/AutistMask/issues/349).
- 2026-10-05: The e2e suite waits for a save to land before it closes the popup
([#446](https://git.eeqj.de/sneak/AutistMask/issues/446)). The Settings round
trip switched the theme and the network and closed the popup at once, and a
close before the save lands loses the switch; with the network left on
Sepolia, a dozen later tests failed too. Each Settings switch and spam-filter
toggle is now waited for in storage before the close, and `reopenPopup()`
waits until the view it expects to reopen on is the saved one. The restore
half of the round trip and the second filter toggle change a setting right
after a reopen, while the reopened popup's own saves may still be running;
they rely on the fix for
[#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
- 2026-10-05: A change made while an earlier save from the same page is still
running is stored ([#448](https://git.eeqj.de/sneak/AutistMask/issues/448)).
`saveStateOnce()` took its baseline from the page's state after the write, so
a change made while the save waited on storage counted as already stored and
the save queued after it wrote nothing. A setting changed during the read was
lost; so was a wallet added, a site revoked or an endpoint changed during the
write, and a wallet deleted then stayed in storage. The save now copies the
page's fields when it starts, writes from that copy, and keeps the copy as the
baseline.
- 2026-10-05: The extension no longer opens a window for a site-connection
prompt already answered
([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was
decided before the toolbar popup raised for it had loaded, that popup was torn
down, `chrome.action.openPopup()` rejected, and the background opened its
fallback window for the answered approval and then removed it. In the Chrome
end-to-end suite the next test could take that window for its own prompt and
lose it under its wait. `openApprovalWindow()` now opens nothing for an
approval that is no longer pending. The blocklist test's Reject, whose window
closes itself, is clicked as the other site Reject is, with the click
witnessed. Making `e2e-chrome` a required check is still blocked: other
reports of the Chrome suite failing under load are open, among them
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290) and
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), as `README.md` says.
- 2026-10-05: The lost-password delete confirmation refuses an empty field and
ignores characters that paint nothing
([#336](https://git.eeqj.de/sneak/AutistMask/issues/336)). A wallet named only
with spaces compared equal to an empty field, so typing nothing would have
deleted it, and a zero-width space in a name made the name impossible to type
back. An empty field is now refused whatever the name is, the same invisible
characters `src/shared/symbolSpoof.js` strips are removed from both sides, and
a name that shows nothing is shown and typed back as "Wallet N".
- 2026-10-05: A `holders_count` that is not a whole number in plain digits is
unknown, not read in part
([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read
`1,000` as 1, `0x10` as 0 and `1e3` as 1, a reported low count that hides the
token in the transaction history and the send-screen token selector. A count
above `Number.MAX_SAFE_INTEGER` is unknown too, not rounded or `Infinity`. The
balance list's `holders !== null` check, which did nothing, is dropped.
`README.md` and `docs/README.md` now say how each filter treats an unknown
count and that the token screen leaves out its "Holders:" row then, and
`README.md` lists `src/shared/holders.js`.
- 2026-10-04: A popup boot in the tests loads transactions without failing
([#429](https://git.eeqj.de/sneak/AutistMask/issues/429)). The stand-in for
`filterTransactions` in `tests/support/popupBoot.js` returned a bare list,
while the real one returns `{ transactions, newFraudContracts }`, so every
boot onto Home, AddressDetail or AddressToken failed inside its transaction
loading and logged `loadHomeTxs failed` or `loadTransactions failed`; the rest
of that code never ran. The stand-in now returns the real shape, and
`tests/persistedFieldContract.test.js` boots onto each of the three and
asserts neither message is logged. `make test` time did not change measurably.
- 2026-10-04: The native token's label follows the network
([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives
each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`,
so on Sepolia the balance, the value and the fee all read `ETH`. Every native
figure now reads `nativeCurrency`, which is `ETH` on mainnet and `SepoliaETH`
on Sepolia: the balance lists, Send and confirmation screens and the
contract-recipient warning the active network's; the approval, wait, success,
error and transaction detail screens, the transaction history and the refusal
of a fee above the limit that of the network the transaction's chain id names.
A token claiming any network's `nativeCurrency` is dropped as a fake, as one
claiming `ETH` already was, and the transaction detail screen calls an entry a
token transfer when it has a token contract, not by its symbol.
- 2026-10-04: A popup that is already open when the stored profile becomes
unreadable moves to the recovery screen
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
the last good profile, with the "NOT SAVED" banner at most, until reopened.
Every save already ran the check the popup runs at open, so the popup finds
the record at the next navigation or ten-second refresh, whether or not the
network answers; a save that fails that check now raises the recovery screen
and stops the refresh. The screen it replaces is left as any navigation leaves
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
else in that popup can replace it, and a later save or a transaction wait that
ends does not clear an export or a typed confirmation. It is never saved as
the current view, so a popup opened after the record is erased in another
window opens normally. Any other failed save still gets the banner and leaves
the screen alone.
- 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is
decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date
reaches only to 275760-09-13, so a later deadline, such as the `uint256`
maximum, made the `Deadline` line throw, and the approval screen showed the
swap as an undecoded contract call with nothing saying why. That line now
reads `After 275760-09-13 00:00:00 (no deadline in practice)`.
- 2026-10-04: The swap decoder reads two router zeros the way the router does
([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in
`amountIn` of zero means an earlier step already sent the tokens to the pair;
`Amount` showed `0.0000` for it and now reads
`Whatever an earlier step sent to the pair (V2 already paid)`. A
`BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it
replaced the minimum an earlier swap step stated, so `Min. received` read
`None (no minimum guaranteed)`; it now sets the output side only when that
side holds no minimum at the point the check is reached. A nonzero
`minBalance` still sets the output side.
- 2026-10-04: The signature screen shows a personal message as the bytes that
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
showed only the decoded text, with bidirectional and zero-width characters
acting on it, so a site could make the message read differently from what is
signed, and a message that was not hex was shown as NUL characters. The hex is
now shown as "Raw data" alongside the decoded text, the text is laid out left
to right in byte order, control characters, line and paragraph separators and
characters that paint nothing are shown as `U+XXXX` marks, and a message that
is not hex by the rule signing reads it with is shown as plain text with
"Sign" disabled, since such a message has no bytes to sign.
- 2026-10-04: A token that reports more than 80 decimal places has no known
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
above 80, so such a token left a swap or an ERC-20 call on the approval screen
undecoded, with nothing saying why. The check now stops at 80, and both
approval paths show the base-unit amount with the scale stated as unknown. The
balance list and the history list use the same check, so the same token no
longer stops an address's token balances from refreshing or its history from
loading.
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
every request's full URL and body, so an RPC endpoint with a key in its path
or query string printed that key on every request. It now logs the HTTP
method, the URL's origin and, for a JSON-RPC call, the method name. The
balance refresh and token lookup log the RPC endpoint by its origin too. A
failed RPC call's error line prints the error's short message, which names the
HTTP status, not its full message, which carries the request URL. A failed
endpoint check in settings names the endpoint by its origin, not the `fetch`
error's message, which carries the whole URL, password included, for a URL
with a user name and password. The README's DEBUG Mode Policy says what debug
mode logs.
- 2026-10-04: A site has at most one connection prompt and one signature prompt - 2026-10-04: A site has at most one connection prompt and one signature prompt
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
`eth_requestAccounts` or `personal_sign` call opened another approval window, `eth_requestAccounts` or `personal_sign` call opened another approval window,
@@ -57,6 +599,15 @@ but the review is broader than any of them.
toolbar popup closed before it connected, and which nothing shows any more, is toolbar popup closed before it connected, and which nothing shows any more, is
shown again when the site asks again. shown again when the site asks again.
- 2026-10-04: A nonce the site supplies with `eth_sendTransaction` is ignored
([#404](https://git.eeqj.de/sneak/AutistMask/issues/404)). It was passed on to
the transaction, so a site could replace one of the user's pending
transactions (same nonce, higher fee) or leave the new one stuck behind a gap,
and the approval screen showed it as a bare number. `nonce` is no longer one
of the fields taken from the request in `src/shared/approvalTx.js`, so the
transaction always gets the account's next nonce from the node, and that is
the nonce the approval screen shows and the popup signs.
- 2026-10-04: Remembered site permissions are held by full origin - 2026-10-04: Remembered site permissions are held by full origin
([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and ([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and
`deniedSites` stored the hostname alone, so a grant to `https://dapp.example` `deniedSites` stored the hostname alone, so a grant to `https://dapp.example`
@@ -1383,6 +1934,3 @@ but the review is broader than any of them.
Only work that has no issue of its own belongs here; everything else is on the Only work that has no issue of its own belongs here; everything else is on the
tracker. tracker.
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.
+100 -25
View File
@@ -15,6 +15,15 @@ const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox"); const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src"); const SRC = path.join(__dirname, "src");
// What `make dev` watches: the directories whose files build() bundles,
// compiles or copies. A change anywhere else, package.json and build.js
// included, starts no build; restart make dev after one.
const WATCHED_DIRS = [
SRC,
path.join(__dirname, "manifest"),
path.join(__dirname, "icons"),
];
// The module whose compiled DEBUG state script/verify-build asserts. Which // The module whose compiled DEBUG state script/verify-build asserts. Which
// bundles contain it is derived from esbuild's own dependency graph rather // bundles contain it is derived from esbuild's own dependency graph rather
// than from a hardcoded list, so it tracks the bundle layout instead of // than from a hardcoded list, so it tracks the bundle layout instead of
@@ -31,7 +40,7 @@ const AUDITED_MODULE = "src/shared/constants.js";
// the build, whether or not a text matcher would have recognized it. A // the build, whether or not a text matcher would have recognized it. A
// background entry point the table does not name fails as well, so a second // background entry point the table does not name fails as well, so a second
// worker is protected by default rather than by someone remembering this file. // worker is protected by default rather than by someone remembering this file.
// Dockerfile:42 runs `make build`, so it is enforced in CI. // The Dockerfile's last stage runs `make build`, so it is enforced in CI.
// The build receipt: every file this build emits, with its sha256 and whether // The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and // it is one of the audited bundles. script/verify-build is handed this and
@@ -441,6 +450,10 @@ function getBuildInfo() {
async function build() { async function build() {
console.log("Building AutistMask extension..."); console.log("Building AutistMask extension...");
// Under make dev this runs once per rebuild in the same process, and each
// build accounts only for what it emits itself.
emittedFiles.length = 0;
const receiptPath = receiptTarget(); const receiptPath = receiptTarget();
if (!receiptPath) { if (!receiptPath) {
console.warn( console.warn(
@@ -575,15 +588,10 @@ async function build() {
copyIcons(distDir); copyIcons(distDir);
} }
// copy manifests // copy manifests, the same files copyIcons() read
copyEmitted( for (const [distDir, manifestPath] of MANIFEST_SOURCES) {
path.join(__dirname, "manifest", "chrome.json"), copyEmitted(manifestPath, path.join(distDir, "manifest.json"));
path.join(DIST_CHROME, "manifest.json"), }
);
copyEmitted(
path.join(__dirname, "manifest", "firefox.json"),
path.join(DIST_FIREFOX, "manifest.json"),
);
assertForbiddenTableCovered(forbiddenRecord); assertForbiddenTableCovered(forbiddenRecord);
@@ -597,27 +605,94 @@ async function build() {
console.log("Build complete: dist/chrome/ and dist/firefox/"); console.log("Build complete: dist/chrome/ and dist/firefox/");
} }
// Run only as a program. Required as a module — which is how // make dev: build, then build again after every change under `dirs`, until
// tests/buildForbiddenInputs.test.js reaches the checks below — this file // interrupted. A failed build is reported and watching carries on, so a
// builds nothing and writes nothing. // half-finished edit does not end it. A change that arrives while a build is
if (require.main === module) { // running is not lost: it causes one more build as soon as that one finishes.
build().catch((err) => { // A directory created after this starts is not watched until a restart.
console.error( //
`Build failed: ${err && err.message ? err.message : err}`, // make dev sets no AUTISTMASK_BUILD_RECEIPT, so these builds write no receipt
); // and nothing can verify what they leave in dist/.
process.exit(1); //
}); // `rebuild` is build() everywhere but tests/buildWatch.test.js, which also
// closes the returned watchers.
function watch(dirs, rebuild) {
let building = false;
let changedAgain = false;
async function run() {
if (building) {
changedAgain = true;
return;
}
building = true;
do {
// Let the rest of one save's events arrive first, so that one
// save is one build.
await new Promise((resolve) => setTimeout(resolve, 100));
changedAgain = false;
try {
await rebuild();
} catch (err) {
console.error(
`Build failed: ${err && err.message ? err.message : err}`,
);
}
} while (changedAgain);
building = false;
console.log("Watching for changes (Ctrl-C to stop)...");
}
// One watcher per directory rather than fs.watch's recursive option: on
// Linux, Node's recursive watch watches each file, and stops seeing one
// that an editor saves by renaming a new copy over it. A directory's
// watcher reports every change to the files in it, however they are saved.
const subdirectories = dirs.flatMap((dir) =>
fs
.readdirSync(dir, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(entry.parentPath, entry.name)),
);
const watchers = [...dirs, ...subdirectories].map((dir) =>
fs.watch(dir, run),
);
run();
return watchers;
} }
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these // Run only as a program. Required as a module — which is how
// three functions enforce is the guarantee behind // tests/buildForbiddenInputs.test.js and tests/buildWatch.test.js reach the
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not // functions below — this file builds nothing and writes nothing.
// run `make build` — so they are unit tested against synthetic metafiles if (require.main === module) {
// rather than being exercised only by CI, where "it ran" is not "it works". const args = process.argv.slice(2);
// An argument this file does not know fails rather than being ignored.
if (args.length > 1 || (args.length === 1 && args[0] !== "--watch")) {
console.error("usage: node build.js [--watch]");
process.exit(2);
}
if (args[0] === "--watch") {
watch(WATCHED_DIRS, build);
} else {
build().catch((err) => {
console.error(
`Build failed: ${err && err.message ? err.message : err}`,
);
process.exit(1);
});
}
}
// Exported for tests only: watch() for tests/buildWatch.test.js, the rest for
// tests/buildForbiddenInputs.test.js. The prohibition those enforce is the
// guarantee behind https://git.eeqj.de/sneak/AutistMask/issues/324, and
// `make check` does not run `make build` — so they are unit tested against
// synthetic metafiles rather than being exercised only by CI, where "it ran"
// is not "it works".
module.exports = { module.exports = {
importChain, importChain,
newForbiddenRecord, newForbiddenRecord,
recordBundledInputs, recordBundledInputs,
assertNoForbiddenInputs, assertNoForbiddenInputs,
assertForbiddenTableCovered, assertForbiddenTableCovered,
watch,
}; };
+10 -2
View File
@@ -240,7 +240,9 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
2. Select what to send (ETH, or any ERC-20 token with a balance on this address 2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters). that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount. 4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
decimal places, or your ETH balance minus the amount reserved for the network
fee.
5. Click "Review" to see the confirmation screen. 5. Click "Review" to see the confirmation screen.
The confirmation screen shows: The confirmation screen shows:
@@ -333,7 +335,13 @@ it is hidden from your transaction history and from the send token list.
from transaction history and the send token list, and are left out of your 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 balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. for address poisoning typically have zero. When the explorer reports no holder
count for a token, or reports something other than a whole number in plain
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
token from your transaction history or the send token list, and it does not get
a token into your balances either: such a token is listed only if it is on the
bundled known-token list or you added it yourself. The screen you reach by
clicking a token balance shows a "Holders:" line only when the count is known.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
+3 -3
View File
@@ -16,9 +16,9 @@ so the "release commit" throughout is the `main` commit the milestone PR merged.
## Procedure ## Procedure
1. **Confirm `main` is green in CI.** The `check` workflow 1. **Confirm `main` is green in CI.** The `check` workflow
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`, (`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
and the `Dockerfile` runs `make check` as a build step, so a green `check` `script/check` and then builds the image uncached, so a green `check` run is
run is a green `make check`. Find the run for the exact release commit on the a green `make check`. Find the run for the exact release commit on the
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
`make check` on a clean checkout of the commit reproduces it and exits 0. `make check` on a clean checkout of the commit reproduces it and exits 0.
+1 -1
View File
@@ -7,7 +7,7 @@
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": { "content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'" "extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
}, },
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", "content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
"32": "icons/icon32.png", "32": "icons/icon32.png",
+2 -2
View File
@@ -15,14 +15,14 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1", "@tailwindcss/cli": "4.3.1",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"eslint": "10.8.1", "eslint": "10.8.1",
"globals": "17.11.0", "globals": "17.11.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"playwright-core": "1.56.0", "playwright-core": "1.56.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tailwindcss": "^4.2.1" "tailwindcss": "4.3.1"
}, },
"dependencies": { "dependencies": {
"ethereum-blockies-base64": "^1.0.2", "ethereum-blockies-base64": "^1.0.2",
+35
View File
@@ -127,6 +127,40 @@ install_js_deps() {
fi fi
} }
# run_node: run node from the repo root, through nvm when node is not on PATH;
# the script comes on stdin
run_node() {
if missing node && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && node"
else
node
fi
}
# yarn install exits 0 without touching node_modules once
# node_modules/.yarn-integrity matches yarn.lock, so a package deleted from
# node_modules stays deleted. Fail unless node finds every package listed in
# dependencies and devDependencies of package.json, by its package.json. When a
# package's exports does not list that file, node throws
# ERR_PACKAGE_PATH_NOT_EXPORTED, which it can only do once it has found the
# package, so that error counts as found.
check_js_deps() {
run_node <<'EOF'
const { dependencies, devDependencies } = require("./package.json");
for (const name of Object.keys({ ...dependencies, ...devDependencies })) {
try {
require.resolve(name + "/package.json");
} catch (e) {
if (e.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") {
console.error(`bootstrap: node cannot find ${name} after yarn install`);
console.error(" fix: rm -rf node_modules && make bootstrap");
process.exit(1);
}
}
}
EOF
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -136,6 +170,7 @@ main() {
ensure_node ensure_node
ensure_yarn ensure_yarn
install_js_deps install_js_deps
check_js_deps
echo "bootstrap complete" echo "bootstrap complete"
} }
+4 -4
View File
@@ -1,14 +1,14 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, test-verify-build, lint, fmt-check). # script/check: run all checks (test, lint, fmt-check). Our own
# Our own extension to scripts-to-rule-them-all. Must not modify any files. # extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/test-verify-build"
"$SCRIPT_DIR/check-censored"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
# script/check-censored: assert that the competitor name RULES.md bars appears # 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 # 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 # deliberate. Our own extension to scripts-to-rule-them-all, run by the
# script/check and from make build. # Dockerfile's lint phase and by make build.
# #
# Where the name is allowed, and why each one is not negotiable away: # Where the name is allowed, and why each one is not negotiable away:
# #
+19 -4
View File
@@ -1,13 +1,28 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so # script/cibuild: run the CI build. It bootstraps first: a CI runner
# a successful build implies all checks pass. # checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build . "$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+23 -16
View File
@@ -3,22 +3,21 @@
# step fails, remove dist/ before returning its exit status. Our own extension # step fails, remove dist/ before returning its exit status. Our own extension
# to scripts-to-rule-them-all, wrapped around every step of make build. # to scripts-to-rule-them-all, wrapped around every step of make build.
# #
# Why: with AUTISTMASK_DEBUG=1 exported in the calling shell, make build # Why: with AUTISTMASK_DEBUG=1 exported, make build compiles a debug bundle and
# compiles a debug bundle and then fails on it in script/verify-build — but the # then fails on it in script/verify-build, after the bundle is written. It is
# bundle is already written. It is loadable, and every wallet it creates gets # loadable, and every wallet it creates gets the publicly committed test
# the publicly committed test recovery phrase from src/shared/constants.js. A # recovery phrase from src/shared/constants.js, so a failed build must not leave
# failed release build that leaves that behind is a smaller version of the trap # it behind. The removal is never silent: it says on stderr that dist/ is gone
# the verifier exists to close, and "the failure was loud" only works on an # and why.
# operator who does not load dist/chrome/ anyway. Removing the artifact does not
# depend on that.
# #
# Two things this deliberately does not do. It does not wrap make build-debug: a # A failed check-censored --require-dist removes dist/ like any other step: a
# debug build that failed is not a mistakable artifact, and its output is the # dist/ not cleared of the name RULES.md bars must not ship either. A step that
# evidence of what went wrong. And it never removes anything on a step that # succeeds removes nothing. An interrupt (Ctrl-C) while a step runs removes
# SUCCEEDS, including the final check-censored --require-dist pass. # nothing and says nothing, even when the step catches it and exits with a
# # status of its own: the wrapper exits with 130 once the step has ended. An
# The removal is never silent: it says dist/ is gone and why, on stderr, above # interrupt is not a build failure, and whoever interrupted the build knows it
# the build's own failure. # did not finish. make build-debug is not wrapped: a debug build that failed is
# not a mistakable artifact, and its output is the evidence of what went wrong.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -66,12 +65,20 @@ main() {
exit 1 exit 1
} }
# An interrupt is not a build failure: once the step has ended, exit
# without removing anything, even if the step caught the interrupt and
# exited with a status of its own. bash as /bin/sh would otherwise carry on.
trap 'exit 130' INT
_status=0 _status=0
"$@" || _status=$? "$@" || _status=$?
[ "$_status" -ne 0 ] || return 0 [ "$_status" -ne 0 ] || return 0
discard_dist # A message that cannot be written, to a closed stderr or to a pipe nobody
# reads any more, must not replace the step's status.
trap '' PIPE
discard_dist || true
exit "$_status" exit "$_status"
} }
+11 -1
View File
@@ -1,6 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" . # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+20 -1
View File
@@ -4,10 +4,29 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Formatting..." echo "Formatting..."
yarn run fmt 2>&1 run_yarn run fmt
} }
main "$@" main "$@"
+20 -1
View File
@@ -5,10 +5,29 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Checking formatting..." echo "Checking formatting..."
yarn run fmt-check 2>&1 run_yarn run fmt-check
} }
main "$@" main "$@"
@@ -14,7 +14,7 @@
// the build when esbuild's own metafile reports src/shared/state.js as an input // the build when esbuild's own metafile reports src/shared/state.js as an input
// of a background bundle. That consults the resolution esbuild actually // of a background bundle. That consults the resolution esbuild actually
// performed, so no specifier syntax and no resolution rule can slip past it, // performed, so no specifier syntax and no resolution rule can slip past it,
// and Dockerfile:42 runs `make build` in CI. // and the Dockerfile's last stage runs `make build` in CI.
// //
// What this rule is: fast local feedback, in the editor and in `make lint`, // What this rule is: fast local feedback, in the editor and in `make lint`,
// before a full bundle. It reads sources from disk and matches import // before a full bundle. It reads sources from disk and matches import
+203
View File
@@ -0,0 +1,203 @@
// Draws the toolbar icon and writes it to every file manifest/chrome.json
// declares under "icons". Run by `make icons`; tests/icons.test.js checks that
// the committed files hold exactly the image this draws.
//
// The icon is a dark-navy rounded square carrying a teal triangle with a
// smaller triangle cut out of it. Every coordinate below is a fraction of the
// icon's side, so one drawing serves every size. A pixel's colour is the
// average of an 8x8 grid of samples, one at the centre of each cell, which
// smooths the edges.
//
// The PNG is written here rather than by a library: RGBA at 8 bits per
// channel, filter type 0 (none) on every row, one IDAT chunk compressed by
// node's zlib at level 9. The committed files were compressed by stock zlib,
// which node's bundled zlib does not reproduce byte for byte, so the image is
// compared rather than the file: the IHDR and every pixel.
"use strict";
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const { icons } = require("../../manifest/chrome.json");
const NAVY = [0x10, 0x1a, 0x2e];
const TEAL = [0x35, 0xe0, 0xc2];
const CORNER_RADIUS = 0.22;
const OUTER_TRIANGLE = [
[0.5, 0.15],
[0.115, 0.855],
[0.885, 0.855],
];
const INNER_TRIANGLE = [
[0.5, 0.4],
[0.29, 0.7],
[0.71, 0.7],
];
const SAMPLES_PER_SIDE = 8;
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
// Points are in pixels from the icon's top-left corner.
function insideRoundedSquare(x, y, size) {
const r = CORNER_RADIUS * size;
// How far the point is past the start of a corner's curve, on each axis.
const dx = Math.max(r - x, 0, x - (size - r));
const dy = Math.max(r - y, 0, y - (size - r));
return dx * dx + dy * dy <= r * r;
}
// Inside or on an edge: the point is not on opposite sides of two edges.
function insideTriangle(x, y, [a, b, c]) {
const side = (p, q) =>
(q[0] - p[0]) * (y - p[1]) - (q[1] - p[1]) * (x - p[0]);
const sides = [side(a, b), side(b, c), side(c, a)];
return !(sides.some((s) => s < 0) && sides.some((s) => s > 0));
}
// Rounds to the nearest integer and a half to the even neighbour, as the
// committed icons were made. Math.round takes a half up, which would change
// some pixels by one.
function roundHalfToEven(v) {
const down = Math.floor(v);
if (v - down !== 0.5) {
return Math.round(v);
}
return down % 2 === 0 ? down : down + 1;
}
// The RGBA bytes of the pixel whose top-left corner is (px, py).
function pixel(px, py, size, outer, inner) {
let inSquare = 0;
let inTeal = 0;
for (let j = 0; j < SAMPLES_PER_SIDE; j++) {
const y = py + (j + 0.5) / SAMPLES_PER_SIDE;
for (let i = 0; i < SAMPLES_PER_SIDE; i++) {
const x = px + (i + 0.5) / SAMPLES_PER_SIDE;
if (!insideRoundedSquare(x, y, size)) {
continue;
}
inSquare++;
if (insideTriangle(x, y, outer) && !insideTriangle(x, y, inner)) {
inTeal++;
}
}
}
if (inSquare === 0) {
return [0, 0, 0, 0];
}
const colour = NAVY.map((navy, k) =>
roundHalfToEven(navy + ((TEAL[k] - navy) * inTeal) / inSquare),
);
const alpha = roundHalfToEven((255 * inSquare) / SAMPLES_PER_SIDE ** 2);
return [...colour, alpha];
}
// A PNG chunk: the data's length, the type, the data, then the CRC-32 of the
// type and the data.
function chunk(type, data) {
const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(zlib.crc32(typeAndData));
return Buffer.concat([length, typeAndData, crc]);
}
// The complete PNG file for the icon at `size` pixels square.
function drawIcon(size) {
const toPixels = (corners) => corners.map(([x, y]) => [x * size, y * size]);
const outer = toPixels(OUTER_TRIANGLE);
const inner = toPixels(INNER_TRIANGLE);
const rows = [];
for (let py = 0; py < size; py++) {
const row = [0]; // filter type 0: the row's bytes are stored as they are
for (let px = 0; px < size; px++) {
row.push(...pixel(px, py, size, outer, inner));
}
rows.push(Buffer.from(row));
}
const header = Buffer.alloc(13); // compression, filter, interlace: all 0
header.writeUInt32BE(size, 0); // width
header.writeUInt32BE(size, 4); // height
header[8] = 8; // bits per channel
header[9] = 6; // colour type: RGBA
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows), { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// The image in a PNG: its IHDR data, and its rows after decompression, each
// row's filter type byte first. With filter type 0 on every row, as drawIcon
// writes, the rows are the pixels themselves; a file using another filter does
// not match even where its pixels do. Refuses a file that is not a PNG, a chunk
// whose CRC-32 is wrong, and any chunk but IHDR, IDAT and IEND, rather than
// ignoring what it cannot compare.
function decodePng(png) {
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new Error("not a PNG");
}
let header = null;
const idat = [];
for (let pos = 8; pos < png.length; ) {
const length = png.readUInt32BE(pos);
const typeAndData = png.subarray(pos + 4, pos + 8 + length);
const type = typeAndData.toString("ascii", 0, 4);
if (zlib.crc32(typeAndData) !== png.readUInt32BE(pos + 8 + length)) {
throw new Error(`the ${type} chunk fails its CRC-32`);
}
if (type === "IHDR") {
header = typeAndData.subarray(4);
} else if (type === "IDAT") {
idat.push(typeAndData.subarray(4));
} else if (type !== "IEND") {
throw new Error(`unexpected ${type} chunk`);
}
pos += 12 + length;
}
if (header === null) {
throw new Error("no IHDR chunk");
}
return { header, rows: zlib.inflateSync(Buffer.concat(idat)) };
}
// True when `file` already holds the image in `png`. A file that is missing or
// cannot be read as a PNG does not, and is written over.
function holdsSameImage(file, png) {
let existing;
try {
existing = decodePng(fs.readFileSync(file));
} catch {
return false;
}
const drawn = decodePng(png);
return (
existing.header.equals(drawn.header) && existing.rows.equals(drawn.rows)
);
}
// A file already holding the drawn image is left alone, so running this does
// not rewrite the committed icons with node's compression.
if (require.main === module) {
for (const [size, file] of Object.entries(icons)) {
const target = path.join(__dirname, "..", "..", file);
const png = drawIcon(Number(size));
if (holdsSameImage(target, png)) {
console.log(`icons: ${file} already holds this image`);
continue;
}
fs.writeFileSync(target, png);
console.log(`icons: wrote ${file}`);
}
}
module.exports = { drawIcon, decodePng };
+13 -41
View File
@@ -1,51 +1,23 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter (eslint, then prettier --check). # script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
# #
# Linting is containerized. ESLint results depend on the ESLint version, and # The phase is not the last stage in the file, so it is built only when
# the pinned one is the one in the image; a host's own install must not be # --target names it. --no-cache because a cached lint layer is a lint
# able to decide whether this repo is green. From a host this therefore builds # that did not run. The tag makes each build replace the previous image
# the Dockerfile's `lint` stage, which runs this same script inside the image. # instead of leaving a dangling one behind.
#
# 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \
case "${AUTISTMASK_LINT_NATIVE:-}" in --target lint \
1) -t "$("$SCRIPT_DIR/projectname")-lint" .
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 "$@" main "$@"
+10 -43
View File
@@ -1,52 +1,19 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. # script/test: run the test suite. Testing is a phase of the Dockerfile
# # and this builds that phase alone, on the same terms as script/lint:
# jest runs three worker processes (package.json), not one per CPU core: on a # --target because a phase that is not the last stage is built only when
# many-core shared host one per core took gigabytes of RAM per run. # named, --no-cache because a cached test layer is a test that did not
# # run, and a tag so each build replaces the previous image.
# The timeout bounds a hung suite; it is not a performance budget. On the busy
# shared build host the suite takes 8-13s with three workers, inside
# REPO_POLICIES' 20s budget. 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Running tests (timeout ${TIMEOUT}s)..." docker build --no-cache \
--target test \
status=0 -t "$("$SCRIPT_DIR/projectname")-test" .
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 "$@" main "$@"
+5 -2
View File
@@ -4,7 +4,7 @@
# scripts-to-rule-them-all. # scripts-to-rule-them-all.
# #
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md # 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 # caps make test at 60 seconds and a browser suite does not fit. Run it
# yourself before touching popup views. ESLint's no-undef now catches a # 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 # used-but-not-imported identifier in make check, but only this suite sees
# what a view actually does when it runs. # what a view actually does when it runs.
@@ -45,7 +45,10 @@ main() {
trap 'cleanup; exit 130' INT TERM trap 'cleanup; exit 130' INT TERM
echo "Building the Chrome e2e image (extension included)..." echo "Building the Chrome e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile . # --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..." 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 image is run by ID, not by tag: where two clones of this repo run
+4 -2
View File
@@ -4,7 +4,7 @@
# script/test-e2e. Our own extension to scripts-to-rule-them-all. # script/test-e2e. Our own extension to scripts-to-rule-them-all.
# #
# Deliberately NOT called by script/check or script/test, for the same # 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 # reason as the Chrome suite: REPO_POLICIES.md caps make test at 60 seconds
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it # and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
# on every push, in a job separate from check. # on every push, in a job separate from check.
# #
@@ -44,7 +44,9 @@ main() {
trap 'cleanup; exit 130' INT TERM trap 'cleanup; exit 130' INT TERM
echo "Building the pinned Firefox e2e image (extension included)..." echo "Building the pinned Firefox e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" \ # --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile . -f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..." echo "Running the Firefox e2e suite..."
+72 -1
View File
@@ -2,7 +2,8 @@
# script/test-verify-build: exercise every failure mode of # script/test-verify-build: exercise every failure mode of
# script/verify-build, and what make build does with dist/ after one of them # script/verify-build, and what make build does with dist/ after one of them
# (script/discard-dist-on-failure). Our own extension to # (script/discard-dist-on-failure). Our own extension to
# scripts-to-rule-them-all, run from script/check so make check covers it. # scripts-to-rule-them-all, run by the Dockerfile's test phase so make check
# covers it.
# #
# Why this exists: verify-build is the build-integrity guard, and four separate # Why this exists: verify-build is the build-integrity guard, and four separate
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation, # reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
@@ -914,6 +915,76 @@ run_cases() {
discard_case "the wrapper given no command removes nothing" \ discard_case "the wrapper given no command removes nothing" \
c_control 1 kept "no command given" "" c_control 1 kept "no command given" ""
# With stderr closed the wrapper cannot write its message, and must still
# remove dist/ and return the step's own status.
build_fixture
_status=0
(cd "$FIXTURE" &&
"$FIXTURE/script/discard-dist-on-failure" sh -c 'exit 6' 2>&-) ||
_status=$?
if [ "$_status" -eq 6 ] && [ ! -e "$FIXTURE/dist" ]; then
PASSED=$((PASSED + 1))
echo " ok: a failed step's status survives a closed stderr"
else
FAILED=$((FAILED + 1))
echo " FAIL: a failed step's status survives a closed stderr"
echo " exit status $_status, wanted 6, and dist/ must be gone"
fi
# The same with stderr a pipe nobody reads any more, where the write would
# kill the wrapper with SIGPIPE. The FIFO's only reader opens it, exits and
# is waited for before the wrapper runs, so the pipe never has a reader.
build_fixture
mkfifo "$WORK/stderr-fifo"
_status=0
(
: <"$WORK/stderr-fifo" &
exec 3>"$WORK/stderr-fifo"
wait "$!"
cd "$FIXTURE" &&
"$FIXTURE/script/discard-dist-on-failure" sh -c 'exit 6' 2>&3
) || _status=$?
if [ "$_status" -eq 6 ] && [ ! -e "$FIXTURE/dist" ]; then
PASSED=$((PASSED + 1))
echo " ok: a failed step's status survives a pipe nobody reads"
else
FAILED=$((FAILED + 1))
echo " FAIL: a failed step's status survives a pipe nobody reads"
echo " exit status $_status, wanted 6, and dist/ must be gone"
fi
# An interrupt while a step runs removes nothing and says nothing, even
# when the step catches it and exits with a status of its own, as
# script/check-censored does. The step interrupts the wrapper and then
# itself, as Ctrl-C interrupts every process of the build at once. Run
# under dash and under bash, which /bin/sh may each be: bash carries on
# after such a step unless the wrapper stops it.
for _shell in dash bash; do
_name="an interrupt under $_shell removes nothing"
if ! command -v "$_shell" >/dev/null 2>&1; then
SKIPPED=$((SKIPPED + 1))
SKIPPED_NAMES="$SKIPPED_NAMES## - $_name ($_shell not found)$NEWLINE"
echo " SKIP ($_shell not found): $_name"
continue
fi
build_fixture
_status=0
_out="$(cd "$FIXTURE" && "$_shell" \
"$FIXTURE/script/discard-dist-on-failure" \
sh -c 'trap "exit 4" INT; kill -INT "$PPID" $$; exit 5' 2>&1)" ||
_status=$?
if [ "$_status" -eq 130 ] && [ -z "$_out" ] &&
[ -f "$FIXTURE/dist/chrome/src/popup/index.js" ]; then
PASSED=$((PASSED + 1))
echo " ok: $_name"
else
FAILED=$((FAILED + 1))
echo " FAIL: $_name"
echo " exit status $_status, wanted 130; dist/ must be intact" \
"and nothing said. Output: $_out"
fi
done
check_makefile_wiring check_makefile_wiring
} }
+32 -7
View File
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
} }
} }
// Open approval in a separate popup window. // Open approval in a separate popup window, unless it is no longer pending.
// This is the primary mechanism for tx/sign approvals (triggered programmatically, // This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals. // not from a user gesture) and the fallback for site-connection approvals.
// Never rejects. Its callers raise it from inside a Promise executor and drop // Never rejects. Its callers raise it from inside a Promise executor and drop
@@ -437,7 +437,13 @@ async function openApprovalWindow(id) {
width: popupWidth, width: popupWidth,
height: popupHeight, height: popupHeight,
}; };
if (currentWin) { // Centred on a browser window only. The last focused window can be
// another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 360x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position.
if (currentWin && currentWin.type === "normal") {
opts.left = Math.round( opts.left = Math.round(
currentWin.left + (currentWin.width - popupWidth) / 2, currentWin.left + (currentWin.width - popupWidth) / 2,
); );
@@ -446,15 +452,32 @@ async function openApprovalWindow(id) {
); );
} }
// Already answered: a site-connection prompt decided before the toolbar
// popup raised for it had loaded, whose openPopup() rejects only now.
if (!pendingApprovals[id]) return;
let win = null; let win = null;
try { try {
win = await windowsCreate(opts); win = await windowsCreate(opts);
} catch (e) { } catch (e) {
// The promise namespace reports the failure by rejecting where the // The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both land // callback namespace reported it by handing back no window; both
// on the !win branch below, which settles the approval. // leave win null.
log.errorf("could not open the approval window:", e); log.errorf("could not open the approval window:", e);
} }
// The browser also refuses a centred position that is too far off screen,
// as it is over a browser window near the screen edge. Asked again
// without a position, it places the window itself. If that fails too,
// the !win branch below settles the approval.
if (!win && opts.left !== undefined) {
delete opts.left;
delete opts.top;
try {
win = await windowsCreate(opts);
} catch (e) {
log.errorf("could not open the approval window:", e);
}
}
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (!approval) { if (!approval) {
@@ -718,11 +741,12 @@ async function handleConnectionRequest(origin) {
} }
} }
// Methods that are safe to proxy directly to the RPC node // Methods that are safe to proxy directly to the RPC node. A method handleRpc
// answers before its proxy branch does not belong here: it would never reach
// the node. tests/proxyMethods.test.js sends every one of these.
const PROXY_METHODS = [ const PROXY_METHODS = [
"eth_blockNumber", "eth_blockNumber",
"eth_call", "eth_call",
"eth_chainId",
"eth_estimateGas", "eth_estimateGas",
"eth_gasPrice", "eth_gasPrice",
"eth_getBalance", "eth_getBalance",
@@ -736,7 +760,6 @@ const PROXY_METHODS = [
"eth_getTransactionReceipt", "eth_getTransactionReceipt",
"eth_maxPriorityFeePerGas", "eth_maxPriorityFeePerGas",
"eth_sendRawTransaction", "eth_sendRawTransaction",
"net_version",
"web3_clientVersion", "web3_clientVersion",
"eth_feeHistory", "eth_feeHistory",
"eth_getBlockTransactionCountByHash", "eth_getBlockTransactionCountByHash",
@@ -1779,3 +1802,5 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false; return false;
} }
}); });
module.exports = { PROXY_METHODS };
+91 -126
View File
@@ -6,7 +6,10 @@
<title>AutistMask</title> <title>AutistMask</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body class="bg-bg text-fg font-mono text-sm"> <!-- Chrome gives extension pages a stylesheet of its own that sets the
font on body, and a Tailwind class beats it only when marked
important: hence font-mono! rather than font-mono. -->
<body class="bg-bg text-fg font-mono! text-sm">
<div id="app" class="p-2 pr-5 overflow-x-hidden"> <div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ --> <!-- ============ GLOBAL TITLE BAR ============ -->
<div <div
@@ -107,8 +110,7 @@
</div> </div>
<div <div
id="add-wallet-phrase-warning" id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2" class="text-xs mb-2 border border-border border-dashed p-2 invisible"
style="visibility: hidden"
> >
Write these words down and keep them safe. Anyone with Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet them can take your funds; if you lose them, your wallet
@@ -205,12 +207,22 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<button <!-- The error line sits beside Import, not above it: at
id="btn-add-wallet-confirm" 360x600 the button already starts near the bottom of
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" the popup, and a line of its own would push it below
> the fold. The longest error fits on one line here. -->
Import <div class="flex items-center gap-2">
</button> <button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
<div
id="add-wallet-password-error"
class="text-xs min-h-[1.25rem] invisible"
></div>
</div>
</div> </div>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ --> <!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
@@ -259,10 +271,7 @@
<!-- recent transactions across all addresses --> <!-- recent transactions across all addresses -->
<div> <div>
<div <div class="font-bold bg-section py-1 px-2 -mx-2">
class="font-bold bg-section py-1 px-2"
style="margin-left: -0.5rem; margin-right: -0.5rem"
>
Recent Transactions Recent Transactions
</div> </div>
<div id="home-tx-list"> <div id="home-tx-list">
@@ -270,7 +279,7 @@
</div> </div>
</div> </div>
<div class="py-1" style="margin: 0 -0.5rem">&nbsp;</div> <div class="py-1 -mx-2">&nbsp;</div>
<div class="text-xs text-muted"> <div class="text-xs text-muted">
<span <span
@@ -392,23 +401,11 @@
></div> ></div>
<h2 class="font-bold mb-1">Export Private Key</h2> <h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p> <p class="text-xs mb-1" id="export-privkey-title"></p>
<div class="text-xs mb-3"> <div id="export-privkey-address" class="text-xs mb-3"></div>
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</div>
<p class="text-xs mb-3 text-muted"> <p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and Warning: anyone with this private key can access and
transfer all funds from this address. Never share it. transfer all funds from this address. Never share it.
</p> </p>
<div
id="export-privkey-flash"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="export-privkey-password-section" class="mb-2"> <div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
<input <input
@@ -417,9 +414,13 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue" placeholder="Enter your password to continue"
/> />
<div
id="export-privkey-password-error"
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
></div>
<button <button
id="btn-export-privkey-confirm" id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Reveal Reveal
</button> </button>
@@ -539,8 +540,7 @@
/> />
<div <div
id="send-to-error" id="send-to-error"
class="text-xs" class="text-xs min-h-[1.25rem] text-[#cc0000]"
style="min-height: 1.25rem; color: #cc0000"
></div> ></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@@ -551,12 +551,20 @@
class="text-xs text-muted" class="text-xs text-muted"
></span> ></span>
</div> </div>
<input <div class="flex gap-1">
type="text" <input
id="send-amount" type="text"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" id="send-amount"
placeholder="0.0" class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
/> placeholder="0.0"
/>
<button
id="btn-send-max"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Max
</button>
</div>
</div> </div>
<button <button
id="btn-send-review" id="btn-send-review"
@@ -608,7 +616,7 @@
<div class="text-xs text-muted mb-1">Your balance</div> <div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div> <div id="confirm-balance" class="text-xs"></div>
</div> </div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden"> <div id="confirm-fee" class="mb-3 invisible">
<div class="text-xs text-muted mb-1">Network fee</div> <div class="text-xs text-muted mb-1">Network fee</div>
<div id="confirm-fee-amount" class="text-xs"></div> <div id="confirm-fee-amount" class="text-xs"></div>
<!-- Holds its one line of space from the first paint, so <!-- Holds its one line of space from the first paint, so
@@ -616,22 +624,13 @@
nothing. The placeholder is never seen. --> nothing. The placeholder is never seen. -->
<div <div
id="confirm-fee-reserve" id="confirm-fee-reserve"
class="text-xs text-muted" class="text-xs text-muted invisible"
style="visibility: hidden"
> >
reserve pending reserve pending
</div> </div>
</div> </div>
<div <div id="confirm-warnings" class="mb-2 invisible"></div>
id="confirm-warnings" <div id="confirm-recipient-warning" class="mb-2 invisible">
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-warning"
class="mb-2"
style="visibility: hidden"
>
<div <div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
> >
@@ -640,24 +639,13 @@
Double-check the address before sending. Double-check the address before sending.
</div> </div>
</div> </div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div <div
id="confirm-contract-warning" id="confirm-contract-warning"
class="mb-2" class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
style="visibility: hidden" ></div>
> <div id="confirm-burn-warning" class="mb-2 invisible">
<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 <div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
> >
@@ -665,11 +653,7 @@
here are permanently destroyed and cannot be recovered. here are permanently destroyed and cannot be recovered.
</div> </div>
</div> </div>
<div <div id="confirm-etherscan-warning" class="mb-2 invisible">
id="confirm-etherscan-warning"
class="mb-2"
style="visibility: hidden"
>
<div <div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
> >
@@ -679,31 +663,26 @@
</div> </div>
<div <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2" class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
style="visibility: hidden; min-height: 1.25rem"
></div> ></div>
<div <div
id="confirm-amount-fee-error" id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
style="visibility: hidden"
> >
Your balance does not cover this amount plus the network Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount. fee. Please go back and send a smaller amount.
</div> </div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div <div
id="confirm-gas-error" id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
style="visibility: hidden" ></div>
>
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
<!-- Its sentence names why the fee could not be estimated, <!-- Its sentence names why the fee could not be estimated,
so show() in confirmTx.js sets it. --> so show() in confirmTx.js sets it. -->
<div <div
id="confirm-fee-unknown-error" id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
@@ -715,8 +694,7 @@
</div> </div>
<div <div
id="confirm-tx-password-error" id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]" class="text-xs mb-2 min-h-[1.25rem] invisible"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-confirm-send" id="btn-confirm-send"
@@ -831,8 +809,7 @@
</button> </button>
<div <div
id="receive-erc20-warning" id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3" class="text-xs border border-border border-dashed p-2 mt-3 invisible"
style="visibility: hidden"
></div> ></div>
</div> </div>
@@ -860,8 +837,7 @@
</div> </div>
<div <div
id="add-token-info" id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem]" class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs text-muted" <label class="block mb-1 text-xs text-muted"
@@ -1047,8 +1023,7 @@
type="text" type="text"
inputmode="numeric" inputmode="numeric"
id="settings-dust-threshold" id="settings-dust-threshold"
class="border border-border p-1 text-xs bg-bg text-fg" class="border border-border p-1 text-xs bg-bg text-fg w-[10ch]"
style="width: 10ch"
/> />
<span class="text-xs text-muted">gwei</span> <span class="text-xs text-muted">gwei</span>
</div> </div>
@@ -1125,8 +1100,7 @@
<div <div
id="settings-debug-well" id="settings-debug-well"
class="bg-well p-3 mx-1 mb-3" class="bg-well p-3 mx-1 mb-3 hidden"
style="display: none"
> >
<h3 class="font-bold mb-1">Debug</h3> <h3 class="font-bold mb-1">Debug</h3>
<label <label
@@ -1152,11 +1126,6 @@
<strong id="delete-wallet-name"></strong> is permanent. Any <strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase. funds will be unrecoverable without your recovery phrase.
</p> </p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
<input <input
@@ -1166,6 +1135,10 @@
placeholder="Enter your password to confirm" placeholder="Enter your password to confirm"
/> />
</div> </div>
<div
id="delete-wallet-password-error"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<button <button
id="btn-delete-wallet-confirm" id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -1228,8 +1201,7 @@
</div> </div>
<div <div
id="delete-wallet-lost-flash" id="delete-wallet-lost-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]" class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-delete-wallet-lost-confirm" id="btn-delete-wallet-lost-confirm"
@@ -1284,8 +1256,7 @@
</p> </p>
<div <div
id="delete-address-flash" id="delete-address-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]" class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-delete-address-confirm" id="btn-delete-address-confirm"
@@ -1312,11 +1283,6 @@
this wallet, from any device, without your password. Never this wallet, from any device, without your password. Never
type them into a website and never show them to anyone. type them into a website and never show them to anyone.
</div> </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"> <div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
<input <input
@@ -1325,9 +1291,13 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue" placeholder="Enter your password to continue"
/> />
<div
id="show-phrase-password-error"
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
></div>
<button <button
id="btn-show-phrase-reveal" id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Reveal Reveal
</button> </button>
@@ -1397,8 +1367,7 @@
/> />
<div <div
id="settings-addtoken-info" id="settings-addtoken-info"
class="text-xs text-muted mt-1 min-h-[1.25rem]" class="text-xs text-muted mt-1 min-h-[1.25rem] invisible"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-settings-addtoken-manual" id="btn-settings-addtoken-manual"
@@ -1631,8 +1600,7 @@
</div> </div>
<div <div
id="approve-tx-error" id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
@@ -1668,15 +1636,7 @@
<div <div
id="approve-sign-danger-warning" id="approve-sign-danger-warning"
class="mb-3 p-2 text-xs font-bold" class="mb-3 p-2 text-xs font-bold invisible min-h-[1.25rem] bg-[#fee2e2] text-[#991b1b] border-2 border-[#dc2626] rounded-[6px]"
style="
visibility: hidden;
min-height: 1.25rem;
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
></div> ></div>
<div class="mb-3"> <div class="mb-3">
@@ -1693,8 +1653,15 @@
<div class="text-xs text-muted mb-1">Message</div> <div class="text-xs text-muted mb-1">Message</div>
<div <div
id="approve-sign-message" id="approve-sign-message"
class="text-xs break-all" class="text-xs break-all max-h-48 overflow-y-auto"
style="max-height: 12rem; overflow-y: auto" ></div>
</div>
<div id="approve-sign-hex-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="approve-sign-hex"
class="text-xs break-all max-h-24 overflow-y-auto"
></div> ></div>
</div> </div>
@@ -1708,8 +1675,7 @@
</div> </div>
<div <div
id="approve-sign-error" id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
@@ -1833,8 +1799,7 @@
</div> </div>
<div <div
id="state-recovery-flash" id="state-recovery-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]" class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-state-recovery-reset" id="btn-state-recovery-reset"
+37 -2
View File
@@ -48,8 +48,20 @@ function renderWalletList() {
home.render(ctx); home.render(ctx);
} }
// Aborted when the popup page goes away, closed or reloaded. Chrome then
// cancels the requests the page still has open, and a cancelled fetch() fails
// with the same "Failed to fetch" as a server that cannot be reached. pagehide
// fires first, so code that reports a failed request checks this and stays
// silent about one the page's own closing cancelled.
const pageClosed = new AbortController();
window.addEventListener("pagehide", () => pageClosed.abort());
let refreshInFlight = false; let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the
// recovery screen: there is no profile left to refresh.
let refreshTimer = null;
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
@@ -62,6 +74,7 @@ async function doRefreshAndRender() {
state.blockscoutUrl, state.blockscoutUrl,
state.trackedTokens, state.trackedTokens,
state.networkId, state.networkId,
pageClosed.signal,
), ),
]); ]);
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
@@ -83,6 +96,7 @@ async function doRefreshAndRender() {
const ctx = { const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
pageClosed: pageClosed.signal,
showAddWalletView: () => { showAddWalletView: () => {
pushCurrentView(); pushCurrentView();
addWallet.show(); addWallet.show();
@@ -155,7 +169,28 @@ async function init() {
// reported rather than being swallowed by the save queue // reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of // (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well. // the approval-window branch below too, since that window saves as well.
onSaveFailure(showSaveFailureBanner); //
// Every save first reads the stored record and refuses it with the same
// check loadState() runs below. So a record that becomes unreadable while
// the popup is open is found by the next save, a navigation or the
// ten-second refresh, and gets the screen it would get at open
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
// screen to showView() first leaves the current screen as any navigation
// does, so a phrase, key or password on it is wiped, and from then on
// showView() shows nothing else. A later save that fails the same way,
// such as a refresh already in flight, comes back here, where both calls
// see the screen already up and do nothing. Any other failed save is a
// read or write that failed, and gets the banner without changing the
// screen.
onSaveFailure((e) => {
if (e instanceof StateUnusableError) {
clearInterval(refreshTimer);
showView("state-recovery");
stateRecovery.show(e);
} else {
showSaveFailureBanner(e);
}
});
try { try {
await loadState(); await loadState();
} catch (e) { } catch (e) {
@@ -244,7 +279,7 @@ async function init() {
renderWalletList(); renderWalletList();
restoreView(); restoreView();
doRefreshAndRender(); doRefreshAndRender();
setInterval(doRefreshAndRender, 10000); refreshTimer = setInterval(doRefreshAndRender, 10000);
} }
} }
+10
View File
@@ -64,3 +64,13 @@ body {
white-space: nowrap; white-space: nowrap;
overflow-x: auto; overflow-x: auto;
} }
/* A personal message on the signature screen is laid out left to right in
* the order of its bytes. Without this, right-to-left characters in it move
* the characters around them: `5`, U+05C3, `00` would read as `500`
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
* the text after it, so src/popup/views/approval.js shows one as a mark. */
.am-byte-order {
direction: ltr;
unicode-bidi: bidi-override;
}
+4
View File
@@ -51,6 +51,7 @@ function init(ctx) {
contractAddr, contractAddr,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed,
); );
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({ state.trackedTokens.push({
@@ -68,6 +69,9 @@ function init(ctx) {
} }
require("./addressDetail").show(); require("./addressDetail").show();
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Adding token failed for", contractAddr, detail); log.errorf("Adding token failed for", contractAddr, detail);
// lookupTokenInfo() rejects a contract with a one-line message // lookupTokenInfo() rejects a contract with a one-line message
+24 -6
View File
@@ -2,6 +2,8 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
goBack, goBack,
clearViewStack, clearViewStack,
onViewLeave, onViewLeave,
@@ -98,6 +100,7 @@ function clear() {
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden"; $("add-wallet-phrase-warning").style.visibility = "hidden";
hideError("add-wallet-password-error");
} }
// Each wallet has its own password (its own encryptedSecret), so adding a // Each wallet has its own password (its own encryptedSecret), so adding a
@@ -125,15 +128,18 @@ function validatePassword() {
const pw = $("add-wallet-password").value; const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value; const pw2 = $("add-wallet-password-confirm").value;
if (!pw) { if (!pw) {
showFlash("Please choose a password."); showError("add-wallet-password-error", "Please choose a password.");
return null; return null;
} }
if (pw.length < 12) { if (pw.length < 12) {
showFlash("Password must be at least 12 characters."); showError(
"add-wallet-password-error",
"Password must be at least 12 characters.",
);
return null; return null;
} }
if (pw !== pw2) { if (pw !== pw2) {
showFlash("Passwords do not match."); showError("add-wallet-password-error", "Passwords do not match.");
return null; return null;
} }
return pw; return pw;
@@ -190,7 +196,12 @@ async function importMnemonic(ctx) {
// Scan for used HD addresses beyond index 0. // Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000); showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId); const scan = await scanForAddresses(
xpub,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
if (scan.addresses.length > 1) { if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
@@ -300,7 +311,12 @@ async function importXprvKey(ctx) {
// Scan for used HD addresses beyond index 0. // Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000); showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId); const scan = await scanForAddresses(
xpub,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
if (scan.addresses.length > 1) { if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
@@ -332,8 +348,10 @@ function init(ctx) {
$("add-wallet-phrase-warning").style.visibility = "visible"; $("add-wallet-phrase-warning").style.visibility = "visible";
}); });
// Import / confirm // Import / confirm. Each press starts with no password error on screen:
// validatePassword() puts it back if the password is still wrong.
$("btn-add-wallet-confirm").addEventListener("click", async () => { $("btn-add-wallet-confirm").addEventListener("click", async () => {
hideError("add-wallet-password-error");
if (currentMode === "mnemonic") { if (currentMode === "mnemonic") {
await importMnemonic(ctx); await importMnemonic(ctx);
} else if (currentMode === "privkey") { } else if (currentMode === "privkey") {
+17 -65
View File
@@ -11,8 +11,10 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -31,8 +33,8 @@ const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the // 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, // 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 // so a wallet whose key getSignerForAddress refuses says so instead of failing
// user has typed one in. // after the user has typed one in.
function selectedWalletDefect() { function selectedWalletDefect() {
if (state.selectedWallet === null) return null; if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]); return walletDefect(state.wallets[state.selectedWallet]);
@@ -64,7 +66,7 @@ function show() {
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr)); const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;"; $("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
@@ -88,62 +90,6 @@ function show() {
loadTransactions(addr.address); loadTransactions(addr.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()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
@@ -153,6 +99,7 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
@@ -188,6 +135,7 @@ async function loadTransactions(address) {
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed,
); );
} catch { } catch {
ensNameMap = new Map(); ensNameMap = new Map();
@@ -196,6 +144,9 @@ async function loadTransactions(address) {
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
$("tx-list").innerHTML = $("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
@@ -234,10 +185,10 @@ function renderTransactions(txs) {
// it on the line above rather than replacing it. // it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
@@ -312,9 +263,10 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => { $("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden"); moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg"); moreBtn.classList.remove("bg-fg", "text-bg");
// There is no private key to export for an address this wallet // This address's private key can be derived from the stored key,
// cannot derive. Without this the export screen would take a // but export goes through getSignerForAddress, which refuses a key
// password and then report it as wrong. // that is not a master key. Without this the export screen would
// take a password and then report that refusal as a wrong password.
const defect = selectedWalletDefect(); const defect = selectedWalletDefect();
if (defect) { if (defect) {
showFlash(defect.shortMessage); showFlash(defect.shortMessage);
+15 -63
View File
@@ -10,14 +10,17 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
balanceLine, balanceLine,
unknownableAmount, unknownableAmount,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { const {
@@ -36,62 +39,6 @@ const { walletDefect } = require("../../shared/walletDefects");
let ctx; let ctx;
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()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
let currentSymbol = null; let currentSymbol = null;
@@ -106,7 +53,7 @@ function show() {
let symbol, amount, price; let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") { if (tokenId === "ETH") {
symbol = "ETH"; symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0"); amount = parseFloat(addr.balance || "0");
price = getPrice("ETH"); price = getPrice("ETH");
} else { } else {
@@ -156,7 +103,7 @@ function show() {
// USD total for this token only // USD total for this token only
const usdVal = price && amount !== null ? amount * price : null; const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;"; $("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here) // Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price); $("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
@@ -201,9 +148,9 @@ function show() {
if (tokenSymbol) if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null) if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
if (tokenHolders != null) if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
if (projectUrl) if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml; contractInfo.innerHTML = infoHtml;
@@ -226,6 +173,7 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
@@ -271,6 +219,7 @@ async function loadTransactions(address, tokenId) {
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed,
); );
} catch { } catch {
ensNameMap = new Map(); ensNameMap = new Map();
@@ -279,6 +228,9 @@ async function loadTransactions(address, tokenId) {
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
$("address-token-tx-list").innerHTML = $("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
@@ -310,10 +262,10 @@ function renderTransactions(txs) {
// it on the line above rather than replacing it. // it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
+81 -29
View File
@@ -10,9 +10,13 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks"); const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { const {
formatEther, formatEther,
formatUnits, formatUnits,
@@ -26,6 +30,7 @@ const {
} = require("ethers"); } = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol, resolveTokenSymbol,
@@ -69,17 +74,6 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) { function decodeCalldata(data, toAddress) {
@@ -218,8 +212,12 @@ function showTxFee(approvedTx) {
const gasLimit = BigInt(approvedTx.gasLimit); const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same // Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. // fee reads the same on both. In the native currency of the network shown
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas); // above, as the value is.
$("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId),
);
let detail = let detail =
gasLimit.toString() + gasLimit.toString() +
@@ -263,6 +261,7 @@ function showTxApproval(details) {
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: approvedTx.chainId,
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
@@ -328,8 +327,15 @@ function showTxApproval(details) {
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: a site can switch the
// active network after this transaction is prepared and back before it is
// signed.
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : ""); ethValueFormatted +
" " +
nativeCurrencyByChainId(approvedTx.chainId) +
(usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx); showTxFee(approvedTx);
@@ -380,20 +386,48 @@ function showTxApproval(details) {
); );
} }
// Whether a personal message is hex by the rule signing reads it with:
// signing takes getBytes(message), which throws on anything else.
function isHexMessage(message) {
try {
getBytes(message);
return true;
} catch {
return false;
}
}
// The text the hex message's bytes decode to as UTF-8, or null when they are
// not UTF-8. The caller has checked that the message is hex.
function decodeHexMessage(hex) { function decodeHexMessage(hex) {
try { try {
const bytes = Uint8Array.from( return toUtf8String(getBytes(hex));
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
} catch { } catch {
return null; return null;
} }
} }
// A character shown as a bordered U+XXXX mark.
function codePointMark(c) {
const code = c.codePointAt(0).toString(16).toUpperCase();
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
}
// The text as HTML, with each character that paints nothing (zero-width and
// bidirectional characters, variation selectors and Hangul fillers among
// them), each control character and each line or paragraph separator
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
// Left in the text, a paragraph separator would end the byte-order layout
// for everything after it. The marks are plain ASCII, so the second pass
// leaves them be.
function markInvisibleCharacters(text) {
return escapeHtml(text)
.replace(INVISIBLE_CHARACTERS, codePointMark)
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
c === "\n" ? "<br>" : codePointMark(c),
);
}
// The type ethers will sign typed data as. ethers does not read the page's // The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct // `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers // refers to. Throws when the types name no such single struct, which ethers
@@ -657,15 +691,33 @@ function showSignApproval(details) {
? "Typed data (EIP-712)" ? "Typed data (EIP-712)"
: "Personal message"; : "Personal message";
// A personal message is signed as the bytes its hex encodes, so the hex
// is shown as well as any text it decodes to, and that text is laid out
// left to right in the order of its bytes. Signing reads the bytes from
// the hex, so a message that is not hex cannot be signed: it is shown as
// the text it is, and refused.
let refusal = null;
$("approve-sign-hex-section").classList.add("hidden");
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
if (isTyped) { if (isTyped) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData); $("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else { refusal = typedDataRefusal(sp);
} else if (isHexMessage(sp.message)) {
const decoded = decodeHexMessage(sp.message); const decoded = decodeHexMessage(sp.message);
if (decoded !== null) { if (decoded !== null) {
$("approve-sign-message").textContent = decoded; $("approve-sign-message").innerHTML =
markInvisibleCharacters(decoded);
} else { } else {
$("approve-sign-message").textContent = sp.message; $("approve-sign-message").textContent = "This message is not text.";
} }
$("approve-sign-hex").textContent = sp.message;
$("approve-sign-hex-section").classList.remove("hidden");
} else {
$("approve-sign-message").innerHTML = markInvisibleCharacters(
sp.message,
);
refusal =
"This message is plain text, not hex, so it cannot be signed.";
} }
// Display danger warning for eth_sign (raw hash signing) // Display danger warning for eth_sign (raw hash signing)
@@ -687,7 +739,6 @@ function showSignApproval(details) {
showView("approve-sign"); showView("approve-sign");
attachCopyHandlers("view-approve-sign"); attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) { if (refusal) {
showError("approve-sign-error", refusal); showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true; $("btn-approve-sign").disabled = true;
@@ -771,9 +822,10 @@ function setSignButtonBusy(busy) {
} }
// Say so on the approval screen itself, and disable the approve button, when // 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 // the address the approval was raised for belongs to a wallet whose key
// cannot be derived. Without this the screen would take a password and fail // getSignerForAddress refuses. Without this the screen would take a password
// after deriving it. Reject stays available; the wallet is not touched. // and fail after deriving it. Reject stays available; the wallet is not
// touched.
// Returns true when it gated. // Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId, address) { function gateOnWalletDefect(errorId, buttonId, address) {
const owner = findWalletFor(address); const owner = findWalletFor(address);
+112 -37
View File
@@ -11,14 +11,17 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave, onViewLeave,
formatFee, formatFee,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
@@ -42,10 +45,10 @@ const {
FEE_UNAVAILABLE, FEE_UNAVAILABLE,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
validateTransfer, validateTransfer,
} = require("../../shared/txValidation"); } = require("../../shared/txValidation");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
let pendingTx = null; let pendingTx = null;
@@ -53,6 +56,10 @@ let pendingTx = null;
// filled in by estimateGas() when the estimate resolves or fails. // filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING; let feeStatus = FEE_PENDING;
let feeWei = null; let feeWei = null;
// The fee fields a max ETH send is signed with: those of the estimate its
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
let maxSendFees = null;
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -61,11 +68,6 @@ function restore() {
} }
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
@@ -81,16 +83,30 @@ function valueWithUsd(text, usdAmount) {
return text; return text;
} }
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the // The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the // screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped. // price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH"; const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const symbol = displaySymbol(rawSymbol); const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
@@ -98,7 +114,7 @@ function show(txInfo) {
$("confirm-type").textContent = $("confirm-type").textContent =
"ERC-20 token transfer (" + symbol + ")"; "ERC-20 token transfer (" + symbol + ")";
} else { } else {
$("confirm-type").textContent = "Native ETH transfer"; $("confirm-type").textContent = "Native " + symbol + " transfer";
} }
// Token contract section (ERC-20 only) // Token contract section (ERC-20 only)
@@ -131,18 +147,11 @@ function show(txInfo) {
); );
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
// Amount (with inline USD) renderAmount(txInfo);
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + symbol,
amountUsd,
);
// Balance (with inline USD) // Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) { if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero // null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is // (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -162,7 +171,7 @@ function show(txInfo) {
const bal = txInfo.balance || "0"; const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null; const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd( $("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH", truncateAmountNeverZero(bal) + " " + symbol,
balUsd, balUsd,
); );
} }
@@ -197,6 +206,19 @@ function show(txInfo) {
// estimate landing later never moves anything. // estimate landing later never moves anything.
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20); $("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20); $("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
// The fee-unknown message names its cause, which is also known here. // The fee-unknown message names its cause, which is also known here.
// Without the token's scale estimateGas() cannot encode the transfer, so // Without the token's scale estimateGas() cannot encode the transfer, so
@@ -245,7 +267,9 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything. // touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH"; const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
@@ -258,7 +282,7 @@ function renderValidation(txInfo) {
// Messages carrying the user's own numbers are built here; the fixed // Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the // sentences live in the reserved elements in index.html, except the
// fee-unknown one, which show() sets. // gas and fee-unknown ones, which show() sets.
const messages = []; const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) { if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send."); messages.push("Please enter a valid amount to send.");
@@ -287,9 +311,13 @@ function renderValidation(txInfo) {
messages.push( messages.push(
"Insufficient balance. You have " + "Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") + truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " + " " +
symbol +
" but are trying to send " +
txInfo.amount + txInfo.amount +
" ETH.", " " +
symbol +
".",
); );
} }
@@ -360,8 +388,8 @@ async function estimateGas(txInfo) {
} }
// What the node will require to be reserved, which is what the gate // 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 // must be: the send is broadcast as a type-2 transaction priced at
// type-2 transaction priced at maxFeePerGas. // maxFeePerGas, which only a max ETH send pins (see below).
const gasCostWei = feeReserveWei(gasLimit, feeData); const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) { if (gasCostWei === null) {
throw new Error("no usable gas price from the provider"); throw new Error("no usable gas price from the provider");
@@ -378,24 +406,54 @@ async function estimateGas(txInfo) {
// The fee line goes through formatFee(), as the approval screen's // The fee line goes through formatFee(), as the approval screen's
// does, so the same fee reads the same on both. // does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei); $("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved"; " " +
nativeCurrency() +
" reserved";
setVisible("confirm-fee-reserve", true); setVisible("confirm-fee-reserve", true);
} else { } else {
// No spread to report: either there is no estimate, or the node // No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected // quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone. // cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(gasCostWei); $("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
);
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
feeWei = gasCostWei; feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the
// Send screen's, and is signed with this estimate's fee fields: fees
// fetched again at signing could exceed the reserve it leaves, and the
// node would refuse it for want of funds. Where the balance no longer
// covers the fee, the amount is left as it is and the balance check
// below says so.
if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
// or gasPrice on a network with no type-2 pricing.
if (feeData.maxFeePerGas != null) {
maxSendFees = {
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
};
} else {
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
}
}
}
renderValidation(txInfo); renderValidation(txInfo);
} catch (e) { } catch (e) {
log.errorf("gas estimation failed:", e.message); log.errorf("gas estimation failed:", e.shortMessage || e.message);
if (pendingTx !== txInfo) return; if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate"; $("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
@@ -406,18 +464,19 @@ async function estimateGas(txInfo) {
} }
// Populate the transaction this send describes, enforce the fee bound against // Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. The send // the fees that were actually filled in, then sign and broadcast it. Apart
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what // from a max ETH send, which passes its estimate's fee fields as `fees`, the
// the configured RPC node answers, with nothing otherwise bounding what a // send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one. // hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the // Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when // same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller // the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending. // shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx) { async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request; let request;
if (tx.token === "ETH") { if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount) }; request = { to: tx.to, value: parseEther(tx.amount), ...fees };
} else { } else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner); const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the // The contract's decimals() is read to be COMPARED with the scale the
@@ -479,6 +538,15 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
const defect = walletDefect(wallet);
if (defect) {
showError("confirm-tx-password-error", defect.shortMessage);
return;
}
const password = $("confirm-tx-password").value; const password = $("confirm-tx-password").value;
if (!password) { if (!password) {
showError( showError(
@@ -488,7 +556,6 @@ function init(_ctx) {
return; return;
} }
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret; let decryptedSecret;
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
@@ -508,6 +575,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true; $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted"); $("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx; let tx;
try { try {
const signer = getSignerForAddress( const signer = getSignerForAddress(
@@ -518,7 +589,11 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
tx = await populateVerifyAndSend(connectedSigner, pendingTx); tx = await populateVerifyAndSend(
connectedSigner,
pendingTx,
maxSendFees,
);
// Best-effort: clear decrypted secret after use. // Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but // Note: JS strings are immutable; this nulls the reference but
+64 -29
View File
@@ -2,6 +2,8 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
goBack, goBack,
clearViewStack, clearViewStack,
onViewLeave, onViewLeave,
@@ -12,6 +14,7 @@ const {
removeWalletFromState, removeWalletFromState,
broadcastActiveChanged, broadcastActiveChanged,
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null; let lostPasswordIndex = null;
@@ -20,32 +23,41 @@ let ctx = null;
// The name shown for a wallet, and on the lost-password screen the string // The name shown for a wallet, and on the lost-password screen the string
// the user has to type back. One function so the two cannot disagree: a // the user has to type back. One function so the two cannot disagree: a
// confirmation that asks for a name other than the one on screen is // confirmation that asks for a name other than the one on screen is
// unusable. // unusable. A name that shows nothing at all (only spaces, or only
// zero-width characters) is replaced by "Wallet N" for the same reason:
// there would be nothing on screen to type back.
function displayName(walletIdx) { function displayName(walletIdx) {
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1); const name = wallet && wallet.name;
if (name && confirmKey(name)) return name;
return "Wallet " + (walletIdx + 1);
} }
// What the typed confirmation and the wallet name are compared as. HTML // What the typed confirmation and the wallet name are compared as. HTML
// collapses runs of whitespace when it renders the name, so a wallet named // collapses runs of whitespace when it renders the name, so a wallet named
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot // "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
// see the second space and cannot type a string that matches the stored // see the second space and cannot type a string that matches the stored
// name. Comparing collapsed on both sides is what keeps the confirmation // name. Characters that paint nothing, such as a zero-width space, are
// satisfiable, on the one screen whose whole purpose is unwedging a user // invisible the same way and are removed first. Comparing this form on
// who is already stuck. Case and surrounding space go the same way. // both sides is what keeps the confirmation satisfiable, on the one screen
// whose whole purpose is unwedging a user who is already stuck. Case and
// surrounding space go the same way.
function confirmKey(name) { function confirmKey(name) {
return name.trim().replace(/\s+/g, " ").toLowerCase(); return name
.replace(INVISIBLE_CHARACTERS, "")
.trim()
.replace(/\s+/g, " ")
.toLowerCase();
} }
// Drop the password from the DOM and the wallet selection from the // 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, // closure. Run by the view-leave handler as well as on entry, so the typed
// so the typed password does not sit in the hidden view after the user // password does not sit in the hidden view after the user navigates away
// navigates away by any route, including the Settings gear. // by any route, including the Settings gear.
function clear() { function clear() {
deleteWalletIndex = null; deleteWalletIndex = null;
$("delete-wallet-password").value = ""; $("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = ""; hideError("delete-wallet-password-error");
$("delete-wallet-flash").style.visibility = "hidden";
} }
// The lost-password screen holds no secret — a wallet name is not one — // The lost-password screen holds no secret — a wallet name is not one —
@@ -136,8 +148,25 @@ async function finishDelete(walletIdx) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear); // Leaving drops the wallet selection, so each screen also comes off the
onViewLeave("delete-wallet-lost-password", clearLostPassword); // Back stack, where the settings gear has just put it: Back from
// Settings must not land on a screen whose button can only answer "No
// wallet selected for deletion." A reopened popup drops them from the
// stack the same way (https://git.eeqj.de/sneak/AutistMask/issues/480).
onViewLeave("delete-wallet-confirm", () => {
clear();
const stack = state.viewStack;
if (stack[stack.length - 1] === "delete-wallet-confirm") {
stack.pop();
}
});
onViewLeave("delete-wallet-lost-password", () => {
clearLostPassword();
const stack = state.viewStack;
if (stack[stack.length - 1] === "delete-wallet-lost-password") {
stack.pop();
}
});
// No wipe here: goBack() routes through showView(), which runs the // No wipe here: goBack() routes through showView(), which runs the
// leave hook. // leave hook.
@@ -174,14 +203,16 @@ function init(_ctx) {
return; return;
} }
// Case, surrounding spaces and repeated inner spaces are not part // Case, surrounding spaces, repeated inner spaces and invisible
// of the confirmation; see confirmKey(). This asks whether the // characters are not part of the confirmation; see confirmKey().
// user knows which wallet they are on; it is not a secret, and // This asks whether the user knows which wallet they are on; it is
// refusing "wallet 2" for "Wallet 2" would only teach the user to // not a secret, and refusing "wallet 2" for "Wallet 2" would only
// distrust the control. // teach the user to distrust the control. An empty field is
const typed = $("delete-wallet-lost-name-input").value; // refused whatever the wallet is called, so no stored name can
// ever be confirmed by typing nothing.
const typed = confirmKey($("delete-wallet-lost-name-input").value);
const expected = displayName(lostPasswordIndex); const expected = displayName(lostPasswordIndex);
if (confirmKey(typed) !== confirmKey(expected)) { if (typed === "" || typed !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent = $("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " + "That is not the name of this wallet. Type " +
expected + expected +
@@ -202,19 +233,22 @@ function init(_ctx) {
$("btn-delete-wallet-confirm").addEventListener("click", async () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value; const pw = $("delete-wallet-password").value;
if (!pw) { if (!pw) {
$("delete-wallet-flash").textContent = showError(
"Please enter your password."; "delete-wallet-password-error",
$("delete-wallet-flash").style.visibility = "visible"; "Please enter your password.",
);
return; return;
} }
if (deleteWalletIndex === null) { if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent = showError(
"No wallet selected for deletion."; "delete-wallet-password-error",
$("delete-wallet-flash").style.visibility = "visible"; "No wallet selected for deletion.",
);
return; return;
} }
hideError("delete-wallet-password-error");
const btn = $("btn-delete-wallet-confirm"); const btn = $("btn-delete-wallet-confirm");
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
@@ -226,9 +260,10 @@ function init(_ctx) {
try { try {
await decryptWithPassword(wallet.encryptedSecret, pw); await decryptWithPassword(wallet.encryptedSecret, pw);
} catch { } catch {
$("delete-wallet-flash").textContent = showError(
"That password is incorrect. Please try again."; "delete-wallet-password-error",
$("delete-wallet-flash").style.visibility = "visible"; "That password is incorrect. Please try again.",
);
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
return; return;
+24 -14
View File
@@ -20,6 +20,8 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
onViewLeave, onViewLeave,
@@ -56,11 +58,6 @@ function isCurrentReveal(generation) {
); );
} }
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 // Wipe every trace of the key and drop the address selection. Safe to call
// when nothing was ever revealed, and safe to call twice. // when nothing was ever revealed, and safe to call twice.
function clear() { function clear() {
@@ -71,8 +68,7 @@ function clear() {
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden"); $("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden"); $("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-flash").textContent = ""; hideError("export-privkey-password-error");
$("export-privkey-flash").style.visibility = "hidden";
} }
function show(walletIdx, addrIdx) { function show(walletIdx, addrIdx) {
@@ -98,7 +94,7 @@ function show(walletIdx, addrIdx) {
$("export-privkey-title").textContent = $("export-privkey-title").textContent =
wallet.name + " — Address " + (addrIdx + 1); wallet.name + " — Address " + (addrIdx + 1);
const addrContainer = $("export-privkey-dot").parentElement; const addrContainer = $("export-privkey-address");
addrContainer.innerHTML = renderAddressHtml(addr.address); addrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(addrContainer); attachCopyHandlers(addrContainer);
@@ -112,15 +108,19 @@ function show(walletIdx, addrIdx) {
async function reveal() { async function reveal() {
const password = $("export-privkey-password").value; const password = $("export-privkey-password").value;
if (!password) { if (!password) {
fail("Please enter your password."); showError(
"export-privkey-password-error",
"Please enter your password.",
);
return; return;
} }
if (walletIndex === null) { if (walletIndex === null) {
fail("No address is selected."); showError("export-privkey-password-error", "No address is selected.");
return; return;
} }
const wallet = state.wallets[walletIndex]; const wallet = state.wallets[walletIndex];
hideError("export-privkey-password-error");
const btn = $("btn-export-privkey-confirm"); const btn = $("btn-export-privkey-confirm");
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
@@ -140,11 +140,12 @@ async function reveal() {
$("export-privkey-password-section").classList.add("hidden"); $("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey; $("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden"); $("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} catch { } catch {
if (!isCurrentReveal(generation)) return; if (!isCurrentReveal(generation)) return;
fail("That password is incorrect. Please try again."); showError(
"export-privkey-password-error",
"That password is incorrect. Please try again.",
);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
@@ -152,7 +153,16 @@ async function reveal() {
} }
function init() { function init() {
onViewLeave(VIEW, clear); // Leaving drops the address selection, so the screen also comes off the
// Back stack, where the settings gear has just put it: Back from Settings
// must not land on a password prompt that can only fail. A reopened popup
// drops it from the stack the same way
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
onViewLeave(VIEW, () => {
clear();
const stack = state.viewStack;
if (stack[stack.length - 1] === VIEW) stack.pop();
});
// No wipe here: goBack() routes through showView(), which runs the // No wipe here: goBack() routes through showView(), which runs the
// leave hook. A per-button wipe would only cover this one path. // leave hook. A per-button wipe would only cover this one path.
+99 -33
View File
@@ -13,10 +13,12 @@
// reasoning behind it are; it is re-exported below so views keep importing // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { formatEther } = require("ethers"); const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -52,9 +54,8 @@ const VIEWS = [
"export-privkey", "export-privkey",
"show-phrase", "show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile // Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read. It is never reached through showView() — by then the // cannot be read, never by showView() (see there), but listed so that
// state singleton this file writes on every navigation refuses to be read // every view-hiding loop covers it.
// — but it is listed so that every view-hiding loop covers it.
"state-recovery", "state-recovery",
]; ];
@@ -85,12 +86,28 @@ function hideError(id) {
el.style.visibility = "hidden"; el.style.visibility = "hidden";
} }
// Set when src/popup/index.js passes the recovery screen to showView(), and
// never cleared. Kept in memory for this popup's life, never in
// state.currentView, which is saved: a popup opened later must not inherit it.
let stateRecoveryShown = false;
function showView(name) { function showView(name) {
// The recovery screen, once up, is never replaced: work still running
// when it went up, such as a transaction wait, must not take the user off
// it or clear what they exported or typed there
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
if (stateRecoveryShown) return;
const leaving = state.currentView; const leaving = state.currentView;
if (leaving && leaving !== name) { if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving); const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave(); if (onLeave) onLeave();
} }
// Passed here only so the screen it replaces is left like any other;
// stateRecovery.show() raises it, and it is never the current view.
if (name === "state-recovery") {
stateRecoveryShown = true;
return;
}
for (const v of VIEWS) { for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`); const el = document.getElementById(`view-${v}`);
if (el) { if (el) {
@@ -199,10 +216,21 @@ function pushCurrentView() {
// Pop the navigation stack and show the previous view. If the stack // Pop the navigation stack and show the previous view. If the stack
// is empty, fall back to the main (home) view. // is empty, fall back to the main (home) view.
//
// An entry for the view already showing is skipped: landing on it would
// make Back seem to do nothing. Settings, the recovery phrase or delete
// wallet screen, then the gear leaves Settings under Settings, because that
// screen takes itself off the stack when left, and a reopened popup cuts it
// off the restored stack the same way
// (https://git.eeqj.de/sneak/AutistMask/issues/481).
function goBack() { function goBack() {
const stack = state.viewStack;
while (stack.length > 0 && stack[stack.length - 1] === state.currentView) {
stack.pop();
}
let target; let target;
if (state.viewStack.length > 0) { if (stack.length > 0) {
target = state.viewStack.pop(); target = stack.pop();
} else { } else {
target = "main"; target = "main";
} }
@@ -252,16 +280,42 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
} }
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// the Send and confirmation screens, which send on the active network, label a
// native amount with this; a transaction already made or requested is labelled
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
// id, not its label, and stays "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// ETH price is known. The USD value is of the exact fee, not of the truncated // native currency of the network the fee is paid on, then its USD value when
// figure. // the ETH price is known. The USD value is of the exact fee, not of the
function formatFee(wei) { // truncated figure.
function formatFee(wei, symbol) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return ( return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "") truncateAmountNeverZero(eth) +
" " +
symbol +
(usd ? " (" + usd + ")" : "")
); );
} }
@@ -282,7 +336,7 @@ function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd = const usd =
price && amount !== null price && amount !== null
? formatUsd(amount * price) || "&nbsp;" ? escapeHtml(formatUsd(amount * price)) || "&nbsp;"
: "&nbsp;"; : "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it // tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute. // lands inside a quoted attribute.
@@ -292,7 +346,7 @@ function balanceLine(symbol, amount, price, tokenId) {
: ""; : "";
return ( return (
`<div class="flex text-xs${clickClass}"${tokenAttr}>` + `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between" style="width:42ch;max-width:100%">` + `<span class="flex justify-between w-[42ch] max-w-full">` +
`<span>${escapeHtml(displaySymbol(symbol))}</span>` + `<span>${escapeHtml(displaySymbol(symbol))}</span>` +
`<span>${qty}</span>` + `<span>${qty}</span>` +
`</span>` + `</span>` +
@@ -303,7 +357,7 @@ function balanceLine(symbol, amount, price, tokenId) {
function balanceLinesForAddress(addr, trackedTokens, showZero) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
"ETH", nativeCurrency(),
parseFloat(addr.balance || "0"), parseFloat(addr.balance || "0"),
getPrice("ETH"), getPrice("ETH"),
"ETH", "ETH",
@@ -387,23 +441,26 @@ function truncateMiddle(str, maxLen) {
// 16 colors evenly spaced around the hue wheel (22.5° apart), // 16 colors evenly spaced around the hue wheel (22.5° apart),
// all at HSL saturation 70%, lightness 50% for uniform vibrancy. // all at HSL saturation 70%, lightness 50% for uniform vibrancy.
// Each is a whole Tailwind class: Tailwind builds only the classes it finds
// written out in the source, so the class name cannot be put together at
// runtime.
const ADDRESS_COLORS = [ const ADDRESS_COLORS = [
"#d92626", "bg-[#d92626]",
"#d96926", "bg-[#d96926]",
"#d9ac26", "bg-[#d9ac26]",
"#c2d926", "bg-[#c2d926]",
"#80d926", "bg-[#80d926]",
"#3dd926", "bg-[#3dd926]",
"#26d953", "bg-[#26d953]",
"#26d996", "bg-[#26d996]",
"#26d9d9", "bg-[#26d9d9]",
"#2696d9", "bg-[#2696d9]",
"#2653d9", "bg-[#2653d9]",
"#3d26d9", "bg-[#3d26d9]",
"#8026d9", "bg-[#8026d9]",
"#c226d9", "bg-[#c226d9]",
"#d926ac", "bg-[#d926ac]",
"#d92669", "bg-[#d92669]",
]; ];
function addressColor(address) { function addressColor(address) {
@@ -413,7 +470,12 @@ function addressColor(address) {
function addressDotHtml(address) { function addressDotHtml(address) {
const color = addressColor(address); const color = addressColor(address);
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`; return `<span class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle shrink-0"></span>`;
}
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" class="inline-block rounded-[50%] [image-rendering:pixelated]">`;
} }
// Look up an address across all wallets and return its title // Look up an address across all wallets and return its title
@@ -462,6 +524,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// A transaction's time as every screen shows it (README, Display
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
// on, and the relative age. Views import these two; they keep no copies.
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -520,7 +585,7 @@ function timeAgo(timestamp) {
// Shared external-link icon SVG used across all views. // Shared external-link icon SVG used across all views.
const EXT_ICON = const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` + `<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` + `<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="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"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
@@ -660,9 +725,11 @@ module.exports = {
balanceLinesForAddress, balanceLinesForAddress,
addressHoldsFunds, addressHoldsFunds,
unknownableAmount, unknownableAmount,
nativeCurrency,
tokenLabel,
formatFee, formatFee,
addressColor,
addressDotHtml, addressDotHtml,
blockieHtml,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
addressTitle, addressTitle,
@@ -672,7 +739,6 @@ module.exports = {
renderAddressHtml, renderAddressHtml,
copyableHtml, copyableHtml,
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
EXT_ICON, EXT_ICON,
+25 -11
View File
@@ -10,11 +10,17 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const {
state,
saveState,
currentAddress,
currentNetwork,
} = require("../../shared/state");
const { notify } = require("../../shared/browserApi"); const { notify } = require("../../shared/browserApi");
const { const {
updateSendBalance, updateSendBalance,
@@ -57,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
if (priceEl) { if (priceEl) {
priceEl.innerHTML = ethPrice priceEl.innerHTML = ethPrice
? formatUsd(ethPrice) + " USD/ETH" ? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
: "&nbsp;"; : "&nbsp;";
} }
@@ -68,12 +74,13 @@ function renderTotalValue() {
return; return;
} }
const ethBal = parseFloat(addr.balance || "0"); const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " ETH"; const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : ""; const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
if (subEl) { if (subEl) {
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;"; subEl.innerHTML =
escapeHtml(formatAddressTotal(getAddressValue(addr))) || "&nbsp;";
} }
} }
@@ -124,10 +131,10 @@ function renderHomeTxList(ctx) {
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
const titleStr = title ? escapeHtml(title) : ""; const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`; html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr); html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`; html += `</div>`;
@@ -182,7 +189,11 @@ async function loadHomeTxs(ctx) {
try { try {
const fetches = allAddresses.map((addr) => const fetches = allAddresses.map((addr) =>
fetchRecentTransactions(addr, state.blockscoutUrl), fetchRecentTransactions(
addr,
state.blockscoutUrl,
currentNetwork().chainId,
),
); );
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
@@ -214,6 +225,9 @@ async function loadHomeTxs(ctx) {
homeTxs = merged.slice(0, 25); homeTxs = merged.slice(0, 25);
renderHomeTxList(ctx); renderHomeTxList(ctx);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message); log.errorf("loadHomeTxs failed:", e.message);
const list = $("home-tx-list"); const list = $("home-tx-list");
if (list) { if (list) {
@@ -230,7 +244,7 @@ function walletListHtml() {
state.wallets.forEach((wallet, wi) => { state.wallets.forEach((wallet, wi) => {
const defect = walletDefect(wallet); const defect = walletDefect(wallet);
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
// No "+" on a defective wallet: deriving another address from that // No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce // xpub would only add one more address the key does not produce
@@ -244,12 +258,12 @@ function walletListHtml() {
wallet.addresses.forEach((addr, ai) => { 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}">`; 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 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>`; const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg p-0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
// Only where a wallet can spare the address: a wallet holding a // Only where a wallet can spare the address: a wallet holding a
// single address has no remove control, because its last address // single address has no remove control, because its last address
// is never removable. // is never removable.
const removeBtn = canRemoveAddress(wallet) 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>` ? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1 p-0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
: ""; : "";
const dot = addressDotHtml(addr.address); const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
@@ -270,7 +284,7 @@ function walletListHtml() {
} }
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`; html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
+122 -4
View File
@@ -5,6 +5,8 @@ const {
showFlash, showFlash,
addressTitle, addressTitle,
displaySymbol, displaySymbol,
escapeHtml,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -20,10 +22,25 @@ const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { getAddress } = require("ethers"); const {
feeReserveWei,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const { getAddress, parseEther } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Whether the amount field holds what Max filled in. The confirmation screen
// re-derives a max ETH amount from its own fee estimate; typing in the field
// makes it an ordinary amount again.
let amountIsMax = false;
// Counts the times the Send screen has opened, so a Max fee estimate started
// before it was last opened fills nothing in.
let sendScreenOpenings = 0;
/** /**
* Validate a destination address string. * Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }. * Returns { valid: true } or { valid: false, error: "..." }.
@@ -124,7 +141,7 @@ function updateToValidation() {
function renderSendTokenSelect(addr) { function renderSendTokenSelect(addr) {
const sel = $("send-token"); const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>'; sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
const fraudSet = new Set( const fraudSet = new Set(
(state.fraudContracts || []).map((a) => a.toLowerCase()), (state.fraudContracts || []).map((a) => a.toLowerCase()),
); );
@@ -204,7 +221,8 @@ function updateSendBalance() {
$("send-balance").textContent = $("send-balance").textContent =
"Current balance: " + "Current balance: " +
truncateAmountNeverZero(addr.balance || "0") + truncateAmountNeverZero(addr.balance || "0") +
" ETH"; " " +
nativeCurrency();
} else { } else {
const symbol = resolveSymbol( const symbol = resolveSymbol(
token, token,
@@ -226,9 +244,105 @@ function updateSendBalance() {
} }
} }
// Fill the amount field with the most the selected holding can send: a
// token's whole balance (cut to 18 decimal places), or for ETH the exact
// balance minus the fee reserve the confirmation screen checks against, never
// the rounded balance the screen shows. Where there is nothing to fill in, a
// flash message says why.
async function fillMaxAmount() {
const addr = currentAddress();
if (!addr) return;
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
showFlash("This token's balance is zero.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
return;
}
// The fee is estimated for this recipient, as the confirmation screen
// estimates it: sending to a contract can cost more gas.
const to = $("send-to").value.trim();
if (!validateToAddress(to).valid) {
showFlash("Please enter a recipient address first.");
return;
}
const typed = $("send-amount").value;
const opening = sendScreenOpenings;
let feeWei = null;
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const [feeData, gasLimit] = await Promise.all([
provider.getFeeData(),
provider.estimateGas({
from: addr.address,
to,
value: parseEther(addr.balance || "0"),
}),
]);
feeWei = feeReserveWei(gasLimit, feeData);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf(
"max amount fee estimate failed:",
e.shortMessage || e.message,
);
}
// While the estimate was in flight the user left the screen (and perhaps
// opened it again), typed an amount, or changed the address, the holding
// or the recipient: what they did wins.
if (
state.currentView !== "send" ||
sendScreenOpenings !== opening ||
currentAddress()?.address !== addr.address ||
(state.selectedToken || $("send-token").value) !== token ||
$("send-to").value.trim() !== to ||
$("send-amount").value !== typed
) {
return;
}
if (feeWei === null) {
showFlash("The network fee could not be estimated.");
return;
}
const amount = maxEthAmount(addr.balance, feeWei);
if (amount === null) {
showFlash("Your balance does not cover the network fee.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("send-token").addEventListener("change", updateSendBalance); $("send-token").addEventListener("change", () => {
// A filled-in maximum is the maximum of the holding it was filled in
// for.
if (amountIsMax) {
$("send-amount").value = "";
amountIsMax = false;
}
updateSendBalance();
});
$("btn-send-max").addEventListener("click", fillMaxAmount);
$("send-amount").addEventListener("input", () => {
amountIsMax = false;
});
// Initial state: disable review button until address is entered // Initial state: disable review button until address is entered
$("btn-send-review").disabled = true; $("btn-send-review").disabled = true;
@@ -305,6 +419,7 @@ function init(_ctx) {
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance, tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals, tokenDecimals: tokenDecimals,
max: amountIsMax,
}); });
}); });
@@ -315,7 +430,10 @@ function init(_ctx) {
}); });
} }
// Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() { function resetSendValidation() {
sendScreenOpenings++;
amountIsMax = false;
const errorEl = $("send-to-error"); const errorEl = $("send-to-error");
const btn = $("btn-send-review"); const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = ""; if (errorEl) errorEl.textContent = "";
+20 -12
View File
@@ -16,7 +16,12 @@ const {
} = require("../dustThreshold"); } = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log"); const {
log,
debugFetch,
urlOrigin,
setRuntimeDebug,
} = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase"); const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
@@ -208,12 +213,7 @@ function show() {
versionClickCount = 0; versionClickCount = 0;
// Show debug well if debug mode is already enabled // Show debug well if debug mode is already enabled
const debugWell = $("settings-debug-well"); $("settings-debug-well").classList.toggle("hidden", !state.debugMode);
if (state.debugMode) {
debugWell.style.display = "";
} else {
debugWell.style.display = "none";
}
$("settings-debug-mode").checked = state.debugMode; $("settings-debug-mode").checked = state.debugMode;
showView("settings"); showView("settings");
@@ -272,8 +272,14 @@ function init(ctx) {
showFlash("Wrong network: expected " + net.name + "."); showFlash("Wrong network: expected " + net.name + ".");
return; return;
} }
} catch (e) { } catch {
log.errorf("RPC validation fetch failed:", e.message); // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
// Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
log.errorf("RPC validation fetch failed:", urlOrigin(url));
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
@@ -295,8 +301,10 @@ function init(ctx) {
showFlash("Endpoint returned HTTP " + resp.status + "."); showFlash("Endpoint returned HTTP " + resp.status + ".");
return; return;
} }
} catch (e) { } catch {
log.errorf("Blockscout validation failed:", e.message); if (ctx.pageClosed.aborted) return;
// Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
@@ -425,7 +433,7 @@ function init(ctx) {
if (versionClickCount >= 10) { if (versionClickCount >= 10) {
versionClickCount = 0; versionClickCount = 0;
clearTimeout(versionClickTimer); clearTimeout(versionClickTimer);
$("settings-debug-well").style.display = ""; $("settings-debug-well").classList.remove("hidden");
} }
}); });
+6 -2
View File
@@ -12,7 +12,7 @@ function isTracked(address) {
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower); return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
} }
function tokenLabel(t) { function nameAndSymbol(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol; return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
} }
@@ -60,7 +60,7 @@ function renderDropdown() {
let html = '<option value="">-- select --</option>'; let html = '<option value="">-- select --</option>';
for (const t of tokens) { for (const t of tokens) {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : ""); const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${escapeHtml(t.address)}"` + `<option value="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${escapeHtml(t.symbol)}"` +
@@ -135,6 +135,7 @@ function init(_ctx) {
addr, addr,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed,
); );
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({ state.trackedTokens.push({
@@ -152,6 +153,9 @@ function init(_ctx) {
renderDropdown(); renderDropdown();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Adding token failed for", addr, detail); log.errorf("Adding token failed for", addr, detail);
// lookupTokenInfo() rejects a contract with a one-line message // lookupTokenInfo() rejects a contract with a one-line message
+24 -14
View File
@@ -21,6 +21,8 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
onViewLeave, onViewLeave,
@@ -52,11 +54,6 @@ function isCurrentReveal(generation) {
); );
} }
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 // Wipe every trace of the phrase and drop the wallet selection. Safe to
// call when nothing was ever revealed, and safe to call twice. // call when nothing was ever revealed, and safe to call twice.
function clear() { function clear() {
@@ -66,8 +63,7 @@ function clear() {
$("show-phrase-password").value = ""; $("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden"); $("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden"); $("show-phrase-password-section").classList.remove("hidden");
$("show-phrase-flash").textContent = ""; hideError("show-phrase-password-error");
$("show-phrase-flash").style.visibility = "hidden";
} }
function show(walletIdx) { function show(walletIdx) {
@@ -90,19 +86,23 @@ function show(walletIdx) {
async function reveal() { async function reveal() {
const password = $("show-phrase-password").value; const password = $("show-phrase-password").value;
if (!password) { if (!password) {
fail("Please enter your password."); showError("show-phrase-password-error", "Please enter your password.");
return; return;
} }
if (walletIndex === null) { if (walletIndex === null) {
fail("No wallet is selected."); showError("show-phrase-password-error", "No wallet is selected.");
return; return;
} }
const wallet = state.wallets[walletIndex]; const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) { if (!walletHasRecoveryPhrase(wallet)) {
fail("This wallet does not have a recovery phrase."); showError(
"show-phrase-password-error",
"This wallet does not have a recovery phrase.",
);
return; return;
} }
hideError("show-phrase-password-error");
const btn = $("btn-show-phrase-reveal"); const btn = $("btn-show-phrase-reveal");
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
@@ -120,13 +120,14 @@ async function reveal() {
$("show-phrase-password-section").classList.add("hidden"); $("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase; $("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden"); $("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch { } catch {
if (!isCurrentReveal(generation)) return; if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that // Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface. // nothing derived from the ciphertext or the attempt can surface.
fail("That password is incorrect. Please try again."); showError(
"show-phrase-password-error",
"That password is incorrect. Please try again.",
);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
@@ -134,7 +135,16 @@ async function reveal() {
} }
function init() { function init() {
onViewLeave(VIEW, clear); // Leaving drops the wallet selection, so the screen also comes off the
// Back stack, where the settings gear has just put it: Back from Settings
// must not land on a password prompt that can only fail. A reopened popup
// drops it from the stack the same way
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
onViewLeave(VIEW, () => {
clear();
const stack = state.viewStack;
if (stack[stack.length - 1] === VIEW) stack.pop();
});
$("btn-show-phrase-back").addEventListener("click", () => { $("btn-show-phrase-back").addEventListener("click", () => {
goBack(); goBack();
+9 -2
View File
@@ -3,8 +3,10 @@
// Everything else in the popup assumes a loaded profile: showView() reads and // Everything else in the popup assumes a loaded profile: showView() reads and
// writes the state singleton, every view renders from it, and the Settings // writes the state singleton, every view renders from it, and the Settings
// gear leads to a screen that does both. None of that is available here — by // gear leads to a screen that does both. None of that is available here — by
// the time this runs, loadState() has REFUSED, deliberately, and reading the // the time this runs, the stored record has been REFUSED, deliberately: at
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311). // open loadState() refused it and reading the singleton throws
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
// //
// So this module talks to the DOM directly and touches no state at all. It is // So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it // the one screen that must work when nothing else can, which is also why it
@@ -170,6 +172,11 @@ function wire() {
* refused, or its sentence. * refused, or its sentence.
*/ */
function show(problem) { function show(problem) {
// Already up: a later save that trips over the same record, such as a
// refresh that was in flight when the screen went up, must not clear what
// the user has exported or typed here.
if (!$("view-state-recovery").classList.contains("hidden")) return;
const sentence = const sentence =
(problem && (problem.problem || problem.message)) || String(problem); (problem && (problem.problem || problem.message)) || String(problem);
+16 -13
View File
@@ -13,6 +13,7 @@ const {
isoDate, isoDate,
timeAgo, timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
@@ -21,8 +22,8 @@ const {
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
@@ -41,13 +42,10 @@ function getTransactionType(tx) {
return "Token Approval"; return "Token Approval";
return "Contract Call"; return "Contract Call";
} }
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer"; // By the token contract, not the symbol: a token chooses its own symbol
return "Native ETH Transfer"; // and can report the native token's, but only a token transfer has one.
} if (tx.contractAddress) return "ERC-20 Token Transfer";
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
@@ -84,6 +82,11 @@ function show(tx) {
isContractCall: tx.isContractCall || false, isContractCall: tx.isContractCall || false,
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// a site can switch the active network before a later popup
// shows this screen again.
chainId: tx.chainId,
}, },
}; };
render(); render();
@@ -179,7 +182,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to); loadFullTxDetails(tx.hash, tx.to, tx.chainId);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -197,7 +200,7 @@ function showDetailField(sectionId, contentId, value) {
section.classList.remove("hidden"); section.classList.remove("hidden");
} }
function populateOnChainDetails(txData) { function populateOnChainDetails(txData, chainId) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number)); const blockLink = explorerUrl("block", String(txData.block_number));
@@ -227,7 +230,7 @@ function populateOnChainDetails(txData) {
showDetailField( showDetailField(
"tx-detail-fee-section", "tx-detail-fee-section",
"tx-detail-fee", "tx-detail-fee",
feeEth + " ETH", feeEth + " " + nativeCurrencyByChainId(chainId),
); );
} }
@@ -287,7 +290,7 @@ function populateOnChainDetails(txData) {
} }
} }
async function loadFullTxDetails(txHash, toAddress) { async function loadFullTxDetails(txHash, toAddress, chainId) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -304,7 +307,7 @@ async function loadFullTxDetails(txHash, toAddress) {
const txData = await resp.json(); const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData); populateOnChainDetails(txData, chainId);
const inputData = txData.raw_input || txData.input || null; const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return; if (!inputData || inputData === "0x") return;
+17 -19
View File
@@ -13,9 +13,10 @@ const {
explorerUrl, explorerUrl,
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -86,9 +87,13 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait(); endWait();
const id = waitId; const id = waitId;
// A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: a site can switch the active network
// while this screen is open or before a later popup resumes it.
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? "ETH" ? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
@@ -127,13 +132,16 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
try { try {
receipt = await provider.getTransactionReceipt(txHash); receipt = await provider.getTransactionReceipt(txHash);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
// A thrown lookup means "no answer this tick", not "no // A thrown lookup means "no answer this tick", not "no
// receipt": the RPC failed, the chain said nothing. Declaring // receipt": the RPC failed, the chain said nothing. Declaring
// the timeout off it would report a confirmed transaction as // the timeout off it would report a confirmed transaction as
// failed — which matters most on a resumed wait, where the // failed — which matters most on a resumed wait, where the
// first poll is already past the deadline. // first poll is already past the deadline.
answered = false; answered = false;
log.errorf("poll receipt failed:", e.message); log.errorf("poll receipt failed:", e.shortMessage || e.message);
} }
// The lookup is async: the wait may have ended while it was in // The lookup is async: the wait may have ended while it was in
// flight, in which case this result must not touch the view. // flight, in which case this result must not touch the view.
@@ -193,9 +201,10 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of // an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup // 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 // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and // broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol are deliberately unchecked: they are compared and // txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// coalesced rather than dereferenced, and tokenSymbol is null for ETH. // compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -223,7 +232,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? "ETH" ? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
@@ -237,17 +246,6 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;
@@ -320,7 +318,7 @@ function showError(txInfo, txHash, message) {
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? "ETH" ? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
+2 -2
View File
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
}); });
} }
} catch (e) { } catch (e) {
log.errorf("contract check failed:", e.message); log.errorf("contract check failed:", e.shortMessage || e.message);
} }
// Skip tx count check for contracts — they may legitimately have // Skip tx count check for contracts — they may legitimately have
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
}); });
} }
} catch (e) { } catch (e) {
log.errorf("tx count check failed:", e.message); log.errorf("tx count check failed:", e.shortMessage || e.message);
} }
} }
+5 -5
View File
@@ -23,11 +23,11 @@
// disputed is refused rather than guessed at. // disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately // Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result. toDecimals() is that check, shared with the // reporting that call's result. toDecimals() is that check, stopping at the 80
// send path rather than copied: the bundled list stores numbers, the // places formatUnits() accepts, and shared with the send path rather than
// explorer's copy arrives as a string, and a token the user added by hand // copied: the bundled list stores numbers, the explorer's copy arrives as a
// carries whatever lookupTokenInfo() got back, so the accepted types are // string, and a token the user added by hand carries whatever lookupTokenInfo()
// enumerated rather than coerced. // got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
+5 -1
View File
@@ -51,11 +51,15 @@ const POPULATE_TIMEOUT_MS = 20000;
// passed to ethers: the object is page-controlled, and a future ethers that // 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 // learns to carry a new transaction field must not start picking one up out of
// it without this module knowing. // it without this module knowing.
//
// The nonce is not taken from the page; it is always the account's next nonce
// from the network. A page that chose it could replace one of the user's
// pending transactions (the same nonce at a higher fee) or leave this one stuck
// behind a gap (a nonce above the next one).
const REQUEST_FIELDS = [ const REQUEST_FIELDS = [
"to", "to",
"value", "value",
"data", "data",
"nonce",
"gasLimit", "gasLimit",
"gasPrice", "gasPrice",
"maxFeePerGas", "maxFeePerGas",
+13 -2
View File
@@ -54,9 +54,11 @@ const {
formatEther, formatEther,
getAddress, getAddress,
getBytes, getBytes,
toQuantity,
verifyMessage, verifyMessage,
verifyTypedData, verifyTypedData,
} = require("ethers"); } = require("ethers");
const { nativeCurrencyByChainId } = require("./networks");
// The only transaction types this wallet signs: legacy, EIP-2930 and // The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can // EIP-1559. populateTransaction() produces nothing else, so nothing else can
@@ -407,12 +409,21 @@ function assertWithinCeilings(tx) {
price = normalizeQuantity(tx.gasPrice, "gas price"); price = normalizeQuantity(tx.gasPrice, "gas price");
} }
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) { if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
// The fee is paid in the native currency of the network the
// transaction is for. Every caller's transaction names it.
const nativeCurrency = nativeCurrencyByChainId(
present(tx.chainId) ? toQuantity(tx.chainId) : null,
);
throw refuse( throw refuse(
"This transaction would allow a network fee of up to " + "This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) + formatEther(gasLimit * price) +
" ETH, which is more than the " + " " +
nativeCurrency +
", which is more than the " +
formatEther(MAX_TOTAL_FEE) + formatEther(MAX_TOTAL_FEE) +
" ETH this wallet will sign for.", " " +
nativeCurrency +
" this wallet will sign for.",
); );
} }
} }
+58 -27
View File
@@ -10,7 +10,7 @@ const {
} = require("ethers"); } = require("ethers");
const { ERC20_ABI } = require("./constants"); const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks"); const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log"); const { log, debugFetch, urlOrigin } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders"); const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
@@ -87,7 +87,15 @@ function rawUnits(value) {
// way `holders` already is. Absence is never filled in here: this is the // way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented // upstream of every screen that displays a token amount, so a value invented
// at this point is indistinguishable from a real one everywhere below it. // at this point is indistinguishable from a real one everywhere below it.
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) { //
// `signal`, passed by the popup, is aborted when the popup closes; a request
// that fails after that was cancelled by the closing and is not logged.
async function fetchTokenBalances(
address,
blockscoutUrl,
trackedTokens,
signal,
) {
try { try {
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances", blockscoutUrl + "/addresses/" + address + "/token-balances",
@@ -147,20 +155,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// null is a holding of an amount that cannot be stated, which is // null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one. // not the same as a holding of zero, and must never render as one.
const bal = scale === null ? null : formatTokenBalance(raw, scale); const bal = scale === null ? null : formatTokenBalance(raw, scale);
// null means the explorer reported no count, which is not the // null means the explorer reported no readable count, which is
// same as a count of zero. This gate is not the low-holder // not the same as a count of zero. This gate is not the
// display filter: it has no user-facing off switch and governs // low-holder display filter: it has no user-facing off switch and
// the whole balance list, so it stays strict and admits a token // governs the whole balance list, so it stays strict and admits a
// only on a reported count — an unreported one is no evidence. // token only on a reported count — an unreported one is no
// A legitimate token still reaches the list through the known // evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A
// legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried // token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat // through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero. // an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count); const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr); const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see // Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue; if (!isKnown && !isTracked && !hasEnoughHolders) continue;
@@ -190,20 +198,24 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
} }
return balances; return balances;
} catch (e) { } catch (e) {
log.errorf("fetchTokenBalances failed:", e.message); if (!signal?.aborted) {
log.errorf("fetchTokenBalances failed:", e.message);
}
return null; return null;
} }
} }
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `signal` is as for fetchTokenBalances().
async function refreshBalances( async function refreshBalances(
wallets, wallets,
rpcUrl, rpcUrl,
blockscoutUrl, blockscoutUrl,
trackedTokens, trackedTokens,
networkId, networkId,
signal,
) { ) {
log.debugf("refreshBalances start, rpc:", rpcUrl); log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const updates = []; const updates = [];
@@ -220,6 +232,7 @@ async function refreshBalances(
log.debugf("ETH balance", addr.address, addr.balance); log.debugf("ETH balance", addr.address, addr.balance);
}) })
.catch((e) => { .catch((e) => {
if (signal?.aborted) return;
log.errorf( log.errorf(
"ETH balance failed", "ETH balance failed",
addr.address, addr.address,
@@ -243,10 +256,11 @@ async function refreshBalances(
); );
}) })
.catch((e) => { .catch((e) => {
if (signal?.aborted) return;
log.errorf( log.errorf(
"ENS reverse failed", "ENS reverse failed",
addr.address, addr.address,
e.message, e.shortMessage || e.message,
); );
// Keep existing addr.ensName if we had one // Keep existing addr.ensName if we had one
}), }),
@@ -258,6 +272,7 @@ async function refreshBalances(
addr.address, addr.address,
blockscoutUrl, blockscoutUrl,
trackedTokens, trackedTokens,
signal,
).then((balances) => { ).then((balances) => {
if (balances !== null) { if (balances !== null) {
addr.tokenBalances = balances; addr.tokenBalances = balances;
@@ -279,8 +294,9 @@ async function refreshBalances(
// Look up token metadata from its contract. // Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20. // Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) { // `signal` is as for fetchTokenBalances().
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl); async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider); const contract = new Contract(contractAddress, ERC20_ABI, provider);
@@ -289,7 +305,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
symbol = await contract.symbol(); symbol = await contract.symbol();
log.debugf("symbol() =", symbol); log.debugf("symbol() =", symbol);
} catch (e) { } catch (e) {
log.errorf("symbol() failed:", e.shortMessage || e.message); if (!signal?.aborted) {
log.errorf("symbol() failed:", e.shortMessage || e.message);
}
throw new Error("Not a valid ERC-20 token (symbol() failed)."); throw new Error("Not a valid ERC-20 token (symbol() failed).");
} }
@@ -297,7 +315,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
decimals = await contract.decimals(); decimals = await contract.decimals();
log.debugf("decimals() =", decimals); log.debugf("decimals() =", decimals);
} catch (e) { } catch (e) {
log.errorf("decimals() failed:", e.shortMessage || e.message); if (!signal?.aborted) {
log.errorf("decimals() failed:", e.shortMessage || e.message);
}
throw new Error("Not a valid ERC-20 token (decimals() failed)."); throw new Error("Not a valid ERC-20 token (decimals() failed).");
} }
@@ -305,13 +325,21 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
name = await contract.name(); name = await contract.name();
log.debugf("name() =", name); log.debugf("name() =", name);
} catch (e) { } catch (e) {
log.warnf("name() failed, using symbol as name:", e.message); if (!signal?.aborted) {
log.warnf(
"name() failed, using symbol as name:",
e.shortMessage || e.message,
);
}
name = symbol; name = symbol;
} }
// Truncate to prevent storage of excessively long values from RPC // Truncate to prevent storage of excessively long values from RPC.
name = String(name).slice(0, 64); // Counted in code points, as displaySymbol() counts them, so the cut never
symbol = String(symbol).slice(0, 12); // falls between the two halves of an emoji and stores a half that renders
// as U+FFFD.
name = Array.from(String(name)).slice(0, 64).join("");
symbol = Array.from(String(symbol)).slice(0, 12).join("");
log.infof("Token resolved:", symbol, "decimals", Number(decimals)); log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) }; return { name, symbol, decimals: Number(decimals) };
@@ -321,7 +349,8 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire // Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses). // batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }. // Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) { // `signal` is as for fetchTokenBalances().
async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit); log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const used = []; const used = [];
@@ -344,11 +373,13 @@ async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
]); ]);
return { addr, index, isUsed: balance > 0n || txCount > 0 }; return { addr, index, isUsed: balance > 0n || txCount > 0 };
} catch (e) { } catch (e) {
log.errorf( if (!signal?.aborted) {
"scanForAddresses check failed", log.errorf(
addr, "scanForAddresses check failed",
e.shortMessage || e.message, addr,
); e.shortMessage || e.message,
);
}
return { addr, index, isUsed: false }; return { addr, index, isUsed: false };
} }
}), }),
-2
View File
@@ -33,7 +33,6 @@ const DEBUG_MNEMONIC = DEBUG
: null; : null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com"; const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -69,7 +68,6 @@ module.exports = {
BUILD_DEBUG_MARKER, BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL, DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
+14 -4
View File
@@ -32,7 +32,8 @@ function setCache(address, name) {
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() })); localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
} }
async function resolveEnsName(address, rpcUrl, networkId) { // `signal` is as for fetchTokenBalances() in src/shared/balances.js.
async function resolveEnsName(address, rpcUrl, networkId, signal) {
const cached = getCached(address); const cached = getCached(address);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
@@ -42,17 +43,26 @@ async function resolveEnsName(address, rpcUrl, networkId) {
setCache(address, name); setCache(address, name);
return name; return name;
} catch (e) { } catch (e) {
log.errorf("ENS reverse lookup failed", address, e.message); if (!signal?.aborted) {
log.errorf(
"ENS reverse lookup failed",
address,
e.shortMessage || e.message,
);
}
// Don't cache failures — let subsequent lookups retry // Don't cache failures — let subsequent lookups retry
return null; return null;
} }
} }
async function resolveEnsNames(addresses, rpcUrl, networkId) { async function resolveEnsNames(addresses, rpcUrl, networkId, signal) {
const results = new Map(); const results = new Map();
await Promise.all( await Promise.all(
addresses.map(async (addr) => { addresses.map(async (addr) => {
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId)); results.set(
addr,
await resolveEnsName(addr, rpcUrl, networkId, signal),
);
}), }),
); );
return results; return results;
+15 -6
View File
@@ -9,13 +9,22 @@
const LOW_HOLDER_THRESHOLD = 1000; const LOW_HOLDER_THRESHOLD = 1000;
// Parse an explorer-supplied holders_count into a number, or null when the // Parse an explorer-supplied holders_count into a number, or null when it is
// explorer did not report one. Anything unparseable is unknown too: a count // not one. Only a whole number of zero or more, or a string made of nothing
// we cannot read is not a count of zero. // but the digits 0-9, is a count. Anything else is null, never read in part:
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
// null too: a number cannot hold it exactly, so it would come back rounded,
// or as Infinity.
function parseHoldersCount(raw) { function parseHoldersCount(raw) {
if (raw === null || raw === undefined || raw === "") return null; if (typeof raw === "number") {
const n = parseInt(raw, 10); return Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
return Number.isFinite(n) ? n : null; }
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
const count = Number(raw);
return Number.isSafeInteger(count) ? count : null;
}
return null;
} }
// True only for a token the explorer reported as having fewer holders than // True only for a token the explorer reported as having fewer holders than
+25 -5
View File
@@ -42,14 +42,34 @@ const log = {
}, },
}; };
// Fetch wrapper that debug-logs every request and response. // The origin (scheme, host and port) of a URL, for logging in place of the
// URL: RPC providers put API keys in the path or the query string, and a URL
// can carry a user name and password, which the origin leaves out. A URL that
// does not parse gives "", so logging never stops a request.
function urlOrigin(url) {
try {
return new URL(url).origin;
} catch {
return "";
}
}
// Fetch wrapper that debug-logs every request and response. It logs the
// URL's origin and, for a JSON-RPC body, the method name: never the full URL
// or body, which can carry an API key or a signed transaction.
async function debugFetch(url, opts) { async function debugFetch(url, opts) {
const method = (opts && opts.method) || "GET"; const method = (opts && opts.method) || "GET";
const body = opts && opts.body; const origin = urlOrigin(url);
log.debugf("fetch →", method, url, body || ""); let rpcMethod = "";
try {
rpcMethod = JSON.parse(opts.body).method || "";
} catch {
// no body, or a body that is not JSON
}
log.debugf("fetch →", method, origin, rpcMethod);
const resp = await fetch(url, opts); const resp = await fetch(url, opts);
log.debugf("fetch ←", resp.status, url); log.debugf("fetch ←", resp.status, origin);
return resp; return resp;
} }
module.exports = { log, debugFetch, setRuntimeDebug, isDebug }; module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
+8 -5
View File
@@ -76,10 +76,13 @@ function networkByChainId(chainId) {
return null; return null;
} }
// Build a block explorer link for the given path type and value. // The native currency of the network with this chain id. A transaction's
// type: "address" | "tx" | "token" | "block" // value and fee are labelled with the one of the chain the transaction is on,
function explorerLink(network, type, value) { // which need not be the active network. `ETH` when the chain id is missing or
return `${network.explorerUrl}/${type}/${value}`; // no network here has it.
function nativeCurrencyByChainId(chainId) {
const network = networkByChainId(chainId);
return network ? network.nativeCurrency : "ETH";
} }
module.exports = { module.exports = {
@@ -89,5 +92,5 @@ module.exports = {
isKnownNetworkId, isKnownNetworkId,
networkById, networkById,
networkByChainId, networkByChainId,
explorerLink, nativeCurrencyByChainId,
}; };
-23
View File
@@ -104,27 +104,6 @@ function getAddressValue(addr) {
return { usd, partial }; return { usd, partial };
} }
// 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 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 { usd, partial };
}
// The one rendering of an address total, so no screen says it differently. // 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 // A partial total is shown and named as partial: the figure is the ETH and
@@ -149,6 +128,4 @@ module.exports = {
formatUsd, formatUsd,
formatAddressTotal, formatAddressTotal,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
}; };
+12 -5
View File
@@ -122,9 +122,9 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// The persisted fields as they stood at the end of this page's last // The persisted fields as this page held them when its last loadState()
// loadState() or saveState(). saveState() diffs the live state against this // finished, or when its last successful saveState() began. saveState() diffs
// to find only the fields THIS page actually changed. // the live state against this to find only the fields THIS page changed since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -464,7 +464,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. // before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() { async function saveStateOnce() {
const current = snapshotPersisted(); // A copy, so what this save compares and writes is the page's state as it
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -521,7 +524,11 @@ async function saveStateOnce() {
// exactly as it stood; see the note above. // exactly as it stood; see the note above.
rawState.hasWallet = rawState.wallets.length > 0; rawState.hasWallet = rawState.wallets.length > 0;
baseline = structuredClone(snapshotPersisted()); // What this save compared and wrote, not the page's state now: a change
// made during the save must still differ from the baseline, or the save
// queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current;
} }
// showView() calls saveState() on every navigation without awaiting it, so // showView() calls saveState() on every navigation without awaiting it, so
+7 -2
View File
@@ -20,6 +20,10 @@
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever // (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking // truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it. // at is not the whole name — worth knowing before they send to it.
//
// Characters are counted as code points, not UTF-16 units, so an emoji is
// one character and the cut never falls between the two halves of one: a
// half on its own renders as U+FFFD.
const MAX_SYMBOL_LENGTH = 12; const MAX_SYMBOL_LENGTH = 12;
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) { function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol); const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL; if (s.length === 0) return UNKNOWN_SYMBOL;
if (s.length <= MAX_SYMBOL_LENGTH) return s; const chars = Array.from(s);
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…"; if (chars.length <= MAX_SYMBOL_LENGTH) return s;
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
} }
module.exports = { module.exports = {
+9 -3
View File
@@ -11,8 +11,8 @@
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses // 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 // 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 // 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 // one that does is a spoof. "ETH" is one such entry, and every network's
// written so that a second one needs no change here or at any call site. // `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
// //
// The value is a set because a ticker is not unique: seven symbols in the // 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 // bundled list belong to two real contracts each, and answering with one of
@@ -34,6 +34,11 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
// The characters that paint nothing; normalizeSymbol below says which they
// are. The signature screen marks them in a personal message
// (src/popup/views/approval.js).
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
// Fold a symbol onto what a user actually sees, and no further: // Fold a symbol onto what a user actually sees, and no further:
// //
// NFKC collapses compatibility variants that render as the ASCII // NFKC collapses compatibility variants that render as the ASCII
@@ -82,7 +87,7 @@ function normalizeAddress(addr) {
function normalizeSymbol(symbol) { function normalizeSymbol(symbol) {
return String(symbol || "") return String(symbol || "")
.normalize("NFKC") .normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "") .replace(INVISIBLE_CHARACTERS, "")
.trim() .trim()
.toUpperCase(); .toUpperCase();
} }
@@ -104,5 +109,6 @@ function isSpoofedSymbol(symbol, contractAddress) {
} }
module.exports = { module.exports = {
INVISIBLE_CHARACTERS,
isSpoofedSymbol, isSpoofedSymbol,
}; };
+7 -1
View File
@@ -6,6 +6,7 @@
// 511 tokens. // 511 tokens.
const { debugFetch } = require("./log"); const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick"; const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
@@ -3610,7 +3611,9 @@ for (const t of TOKENS) {
// Build a map of symbol (uppercased) -> the set of contract addresses // Build a map of symbol (uppercased) -> the set of contract addresses
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection. // (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may // "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol. // bear its symbol. So does every network's `nativeCurrency` in networks.js
// (`SepoliaETH`), on every network, since that is the label the wallet shows
// its native asset under on that network.
// //
// The value is a set and not a single address because tickers are not unique // 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 // and the list above proves it: seven of these 512 tokens share a symbol with
@@ -3624,6 +3627,9 @@ for (const t of TOKENS) {
// loosen the rule, because a contract outside the set is still a spoof. // loosen the rule, because a contract outside the set is still a spoof.
const KNOWN_SYMBOLS = new Map(); const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null); KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) {
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
}
for (const t of TOKENS) { for (const t of TOKENS) {
const upper = t.symbol.toUpperCase(); const upper = t.symbol.toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) { if (!KNOWN_SYMBOLS.has(upper)) {
+23 -9
View File
@@ -11,6 +11,7 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
// The uint8 test every scale in this wallet goes through. Shared, not copied: // The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the // a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one. // same thing here as it does on the screens that refuse to format one.
@@ -28,7 +29,7 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
function parseTx(tx, addrLower) { function parseTx(tx, addrLower, chainId) {
const from = tx.from?.hash || ""; const from = tx.from?.hash || "";
const to = tx.to?.hash || ""; const to = tx.to?.hash || "";
const rawWei = tx.value || "0"; const rawWei = tx.value || "0";
@@ -36,7 +37,7 @@ function parseTx(tx, addrLower) {
const method = tx.method || null; const method = tx.method || null;
// For contract calls, produce a meaningful label instead of "0.0000 ETH" // For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = "ETH"; let symbol = nativeCurrencyByChainId(chainId);
let value = formatTxValue(formatEther(rawWei)); let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei); let exactValue = formatEther(rawWei);
let rawAmount = rawWei; let rawAmount = rawWei;
@@ -90,10 +91,11 @@ function parseTx(tx, addrLower) {
holders: null, holders: null,
isContractCall: toIsContract, isContractCall: toIsContract,
method: method, method: method,
chainId: chainId,
}; };
} }
function parseTokenTransfer(tt, addrLower) { function parseTokenTransfer(tt, addrLower, chainId) {
const from = tt.from?.hash || ""; const from = tt.from?.hash || "";
const to = tt.to?.hash || ""; const to = tt.to?.hash || "";
// The explorer's own answer, or null. Never a default: a transfer of // The explorer's own answer, or null. Never a default: a transfer of
@@ -135,10 +137,12 @@ function parseTokenTransfer(tt, addrLower) {
contractAddress: normalizeAddress( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "", tt.token?.address_hash || tt.token?.address || "",
), ),
// null when the explorer reported no count: unknown, not zero. The // null when the explorer reported no readable count: unknown, not
// low-holder filter declines to judge a null, so a legitimate token // zero. The low-holder filter declines to judge a null, so a
// is not hidden because a field went missing upstream. // legitimate token is not hidden because a field went missing
// upstream.
holders: parseHoldersCount(tt.token?.holders_count), holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId,
}; };
} }
@@ -221,7 +225,15 @@ function mergeTransactions(txs, tokenTransfers) {
return merged; return merged;
} }
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { // `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
// carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
async function fetchRecentTransactions(
address,
blockscoutUrl,
chainId,
count = 25,
) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address); const addrLower = normalizeAddress(address);
@@ -254,8 +266,10 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions( const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)), (txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)), (ttJson.items || []).map((tt) =>
parseTokenTransfer(tt, addrLower, chainId),
),
); );
const result = txs.slice(0, count); const result = txs.slice(0, count);
+6 -4
View File
@@ -27,9 +27,11 @@
const { parseUnits } = require("ethers"); const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not // Solidity's decimals() returns a uint8, but ethers' formatUnits() and
// an answer this wallet can use. // parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
const MAX_DECIMALS = 255; // decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
// encoded, so it is not an answer this wallet can use, the same as no answer.
const MAX_DECIMALS = 80;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE = const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" + "The transfer was not sent, because the number of decimal places this" +
@@ -55,7 +57,7 @@ function mismatchMessage(displayed, onChain) {
// A decimals value from any source as a number, or null if it is not one. // A decimals value from any source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives // decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything // as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, out of uint8 range, or of any other type at all is not. // fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
// //
// The types are enumerated rather than coerced because Number() is far too // The types are enumerated rather than coerced because Number() is far too
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would // willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
+30 -7
View File
@@ -1,4 +1,4 @@
// Balance arithmetic for the transaction confirmation screen. // Balance arithmetic for the Send and transaction confirmation screens.
// //
// Pure: no DOM, no network, no state. Everything is exact integer math on // 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 // 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -10,7 +10,7 @@
// the token balance arrive as human decimal strings, so comparing them at a // the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact. // common scale is exact.
const { parseUnits } = require("ethers"); const { parseUnits, formatEther } = require("ethers");
const SCALE_DECIMALS = 18; const SCALE_DECIMALS = 18;
@@ -87,6 +87,28 @@ function toFixedPoint(value) {
} }
} }
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
// balances.js stores, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is
// unusable.
function maxEthAmount(ethBalance, feeWei) {
const balanceWei = toFixedPoint(ethBalance);
if (balanceWei === null) return null;
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
const amountWei = balanceWei - feeWei;
if (amountWei <= 0n) return null;
return formatEther(amountWei);
}
// The most of a token a send can carry: its balance cut down, never rounded
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
// declare more than 18 decimals, and its balance is stored with all of them.
function maxTokenAmount(tokenBalance) {
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
}
// Validate a pending transfer against the balances that must cover it. // Validate a pending transfer against the balances that must cover it.
// //
// isErc20 — token transfer rather than a native ETH transfer // isErc20 — token transfer rather than a native ETH transfer
@@ -139,13 +161,12 @@ function validateTransfer({
const feeFp = known ? feeWei : null; const feeFp = known ? feeWei : null;
if (isErc20) { if (isErc20) {
// A token can declare more than 18 decimals, and its balance is // Only the first 18 places of the balance are read: an amount with
// stored with all of them. Only the first 18 places (SCALE_DECIMALS) // more was refused above, so the places after them cannot decide
// are read: an amount with more was refused above, so the places // whether the amount fits.
// after them cannot decide whether the amount fits.
const tokenText = const tokenText =
typeof tokenBalance === "string" typeof tokenBalance === "string"
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1") ? maxTokenAmount(tokenBalance)
: tokenBalance; : tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n; const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN); if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -174,6 +195,8 @@ module.exports = {
SCALE_DECIMALS, SCALE_DECIMALS,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
}; };
+47 -15
View File
@@ -84,17 +84,31 @@ function present(value) {
// //
// `amountOutMinimum` gets no such mapping: V4Router compares it directly // `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so // (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do // a zero minimum is a literal zero slippage floor and is stated as one. Nor
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput` // does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero — // special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero `amountIn` there is a literal zero and is displayed as one. // so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
const OPEN_DELTA = Symbol("v4-open-delta"); const OPEN_DELTA = Symbol("v4-open-delta");
// The two amount lines that state a fact instead of a quantity. Same register // The Universal Router's V2 exact-in spells "the pair already holds the input
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread // tokens" as an amount of zero: universal-router
// as a number — and deliberately not a third phrasing of "not named": these // `contracts/libraries/Constants.sol` declares
// say different things. // `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
// when a v2 pair has already received input tokens"), and
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
// equals it. The swap then spends whatever an earlier step sent to the pair.
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say
// different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const NO_MINIMUM = "None (no minimum guaranteed)"; const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -185,6 +199,7 @@ function decodeBalanceCheck(input) {
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes. // Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
function decodeV2SwapExactIn(input) { function decodeV2SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -192,7 +207,7 @@ function decodeV2SwapExactIn(input) {
input, input,
); );
return { return {
amountIn: d[1], amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
@@ -502,7 +517,13 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x0e) { if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
if (b) setOutput(b.token, b.minBalance); // The router passes this check whenever the owner holds at
// least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
}
} }
if (cmdId === 0x00) { if (cmdId === 0x00) {
@@ -623,14 +644,20 @@ function decode(data, toAddress, sources) {
} }
if (present(inputAmount)) { if (present(inputAmount)) {
// Two amounts need no scale to describe and are named rather than // Three amounts need no scale to describe and are named rather
// formatted: V4's open delta, which is not a quantity at all (see // than formatted: V4's open delta and V2's already-paid zero,
// OPEN_DELTA), and an unbounded permit. The open-delta test comes // neither of which is a quantity at all (see OPEN_DELTA and
// first — the sentinel is not a bigint and cannot be compared with // ALREADY_PAID), and an unbounded permit. Those two tests come
// one. // first — the sentinels are not bigints and cannot be compared
// with one.
let amount; let amount;
if (inputAmount === OPEN_DELTA) { if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount === ALREADY_PAID) {
amount = {
raw: ALREADY_PAID_AMOUNT,
display: ALREADY_PAID_AMOUNT,
};
} else if (inputAmount >= MAX_UINT160) { } else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" }; amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) { } else if (hasV2ExactOut) {
@@ -697,10 +724,15 @@ function decode(data, toAddress, sources) {
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") }); details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
// later deadline, such as the uint256 maximum, makes an invalid date,
// and toISOString() throws on one, so that deadline is said in words.
const deadlineDate = new Date(Number(deadline) * 1000); const deadlineDate = new Date(Number(deadline) * 1000);
details.push({ details.push({
label: "Deadline", label: "Deadline",
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19), value: isNaN(deadlineDate.getTime())
? "After 275760-09-13 00:00:00 (no deadline in practice)"
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
}); });
return { return {
+11 -5
View File
@@ -13,11 +13,17 @@ const NON_MASTER_XPRV = "non-master-xprv";
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below // 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 // 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 // m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
// four levels as a relative path beneath whatever depth it was given. A master // derived the same four levels as a relative path beneath whatever depth it
// import therefore stores a depth-4 xpub and a depth-d import stores depth // was given. A master import therefore stores a depth-4 xpub and a depth-d
// d + 4, which makes the stored xpub an exact read on the imported key's // import stores depth d + 4, which makes the stored xpub an exact read on the
// depth — and it is readable without the password, unlike the key itself. // imported key's depth — and it is readable without the password, unlike the
// key itself.
//
// The first import path (7a7f9c5) does not fit: it stored the imported key's
// own xpub with no derivation, so a wallet it wrote is judged wrongly here (a
// master import as defective, a depth-4 import as sound). 57959b7 replaced it
// in the same push, and no tag contains it.
const BIP44_ETH_XPUB_DEPTH = 4; const BIP44_ETH_XPUB_DEPTH = 4;
const DEFECTS = { const DEFECTS = {
+123
View File
@@ -0,0 +1,123 @@
// Creating a wallet from a recovery phrase or an extended private key does not
// report an address scan request the popup's own closing cancelled, and still
// reports one that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
//
// In the popup a cancelled request fails with the same "Failed to fetch" as a
// server that cannot be reached, so every RPC request here fails that way, and
// only the signal the popup aborts on pagehide tells the two cases apart.
// Driven against the fake elements tests/tokenLookupCancelled.test.js uses.
const { FetchRequest, HDNodeWallet, Mnemonic } = require("ethers");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
let elements;
function fakeElement() {
return {
value: "",
textContent: "",
style: {},
classList: { toggle: () => {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
};
}
// Stands in for document.getElementById(): one fake element per id.
function element(id) {
return (elements[id] ||= fakeElement());
}
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: () => {},
showError: () => {},
hideError: () => {},
goBack: () => {},
clearViewStack: () => {},
onViewLeave: () => {},
}));
// state.js reads chrome.storage.local at load, and saveState() writes it.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const addWallet = require("../src/popup/views/addWallet");
let logged;
beforeEach(() => {
elements = {};
logged = [];
state.wallets = [];
state.rpcUrl = "https://rpc.example.invalid";
state.networkId = "mainnet";
for (const method of ["warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
}
// What the scan found is logged at info level, which is not under test.
jest.spyOn(console, "log").mockImplementation(() => {});
FetchRequest.registerGetUrl(async () => {
throw new TypeError("Failed to fetch");
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
jest.restoreAllMocks();
});
describe.each([
["a recovery phrase", "mnemonic", "wallet-mnemonic", PHRASE],
[
"an extended private key",
"xprv",
"import-xprv-key",
HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed())
.extendedKey,
],
])("creating a wallet from %s", (_name, mode, field, secret) => {
// Enters `secret` on the add-wallet screen, presses its button and waits
// for the address scan that follows.
async function create(pageClosed) {
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
pageClosed,
});
element("tab-" + mode).listeners.click();
element(field).value = secret;
element("add-wallet-password").value = PASSWORD;
element("add-wallet-password-confirm").value = PASSWORD;
await element("btn-add-wallet-confirm").listeners.click();
}
test("a scan failure while the popup is open is reported", async () => {
await create(new AbortController().signal);
expect(state.wallets).toHaveLength(1);
expect(logged).not.toEqual([]);
for (const line of logged) {
expect(line).toContain("scanForAddresses check failed");
}
});
test("a scan failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await create(pageClosed.signal);
expect(state.wallets).toHaveLength(1);
expect(logged).toEqual([]);
});
});
+8 -13
View File
@@ -22,8 +22,6 @@ const {
prices, prices,
clearPrices, clearPrices,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal, formatAddressTotal,
} = require("../src/shared/prices"); } = require("../src/shared/prices");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -136,17 +134,6 @@ describe("the value of an address, and whether it is the whole value", () => {
partial: false, 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", () => { describe("how that value is written on screen", () => {
@@ -207,6 +194,14 @@ describe("the wallet list on Home", () => {
clearPrices(); clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;"); expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
}); });
// A total under a cent is written "< $0.01", and the "<" is escaped
// here as the removal warning escapes it.
test("a total under a cent is escaped, as on the removal warning", () => {
const tiny = { ...EMPTY, balance: "0.000001" };
expect(walletListTotal(tiny)).toBe("Total: &lt; $0.01");
expect(removalWarningTotal(tiny)).toBe("Total: &lt; $0.01");
});
}); });
describe("the balance warning on the address-removal confirmation", () => { describe("the balance warning on the address-removal confirmation", () => {
+50
View File
@@ -31,11 +31,15 @@ const iface = new Interface(ERC20_ABI);
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e"; const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals. // In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// In the bundled list, at 0 decimals.
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111"; const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue. // 5,000 units of a 6-decimal token, the amount from the issue.
const FIVE_THOUSAND_AT_SIX = 5000000000n; const FIVE_THOUSAND_AT_SIX = 5000000000n;
// 5,000 units of a 0-decimal token, which are 5,000 tokens.
const FIVE_THOUSAND_AT_ZERO = 5000n;
const MAX_UINT256 = (1n << 256n) - 1n; const MAX_UINT256 = (1n << 256n) - 1n;
function transferData(amount) { function transferData(amount) {
@@ -113,6 +117,21 @@ describe("resolveTokenDecimals", () => {
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6); expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
}); });
// Zero decimals is a real scale, not a missing one, so a source that
// answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
expect(resolveTokenDecimals(SLP, state)).toBe(0);
});
test("uses a tracked scale of zero", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
];
state.wallets = walletsHolding(NOVEL_TOKEN, 18);
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(0);
});
test("refuses a scale the explorer's own entries disagree about", () => { test("refuses a scale the explorer's own entries disagree about", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6); const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({ wallets[0].addresses.push({
@@ -184,6 +203,22 @@ describe("decodeCalldata amount", () => {
expect(line).not.toMatch(/0\.0000/); expect(line).not.toMatch(/0\.0000/);
}); });
// A token added by hand carries whatever its decimals() returned, and a
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
// call undecoded rather than refused
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("a token reporting more than 80 decimals shows base units", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 81 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000000000 base units (decimals unknown)");
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000000000 base units (decimals unknown)",
);
});
test("an unbounded allowance is still named, with or without a scale", () => { test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe( expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited", "Unlimited",
@@ -197,6 +232,21 @@ describe("decodeCalldata amount", () => {
); );
}); });
test("a bundled token with zero decimals shows the true quantity", () => {
expect(amountLine(transferData(FIVE_THOUSAND_AT_ZERO), SLP)).toBe(
"5000.0000 SLP",
);
});
test("a tracked token with zero decimals shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_ZERO), NOVEL_TOKEN),
).toBe("5000.0000 NOVEL");
});
test("the amount carried to the status screens is the same string", () => { test("the amount carried to the status screens is the same string", () => {
const decoded = decodeCalldata( const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX), transferData(FIVE_THOUSAND_AT_SIX),
+167
View File
@@ -0,0 +1,167 @@
// A nonce the page supplies is not used
// (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could
// replace one of the user's pending transactions (the same nonce at a higher
// fee) or leave the new one stuck behind a gap, so the transaction is always
// given the account's next nonce from the network.
//
// Driven through the preparation the background runs on a page's
// eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen,
// password and Confirm included, against a minimal DOM stub in the shape
// tests/approvalOrigin.test.js uses. The vault is mocked so that no password
// has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Network, Transaction } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const approval = require("../src/popup/views/approval");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The account's next nonce, as the network reports it.
const NETWORK_NONCE = 7;
const network = {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => NETWORK_NONCE,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// Open the transaction approval screen the way the popup does: the background
// hands over the populated transaction and show() draws it. Returns every
// message the screen sends to the background. The background's answer to the
// signed transaction does not matter here; a retryable refusal keeps the
// screen where it is.
async function openTxApproval(approvedTx) {
const sent = [];
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({ error: "Not sent.", retryable: true });
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx,
});
},
};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
},
];
approval.init({});
await approval.show(1);
return sent;
}
test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => {
// What the background does with the page's request before it opens the
// approval window.
const approvedTx = await prepareApprovalTx(network, FROM, {
from: FROM,
to: RECIPIENT,
value: "0x0",
data: "0x",
nonce: "0x2",
});
const sent = await openTxApproval(approvedTx);
expect(node("approve-tx-nonce").textContent).toBe("7");
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE);
});
+2 -2
View File
@@ -121,7 +121,7 @@ describe("prepareApprovalTx", () => {
); );
}); });
test("keeps a nonce, gas limit and fee the request did fix", async () => { test("keeps a gas limit and fee the request did fix, but not its nonce", async () => {
const approved = await prepareApprovalTx( const approved = await prepareApprovalTx(
providerWith(), providerWith(),
signer.address, signer.address,
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
maxPriorityFeePerGas: "0x3b9aca00", maxPriorityFeePerGas: "0x3b9aca00",
}, },
); );
expect(approved.nonce).toBe("0x2"); expect(approved.nonce).toBe("0x7");
expect(approved.gasLimit).toBe("0x30d40"); expect(approved.gasLimit).toBe("0x30d40");
expect(approved.maxFeePerGas).toBe("0x12a05f200"); expect(approved.maxFeePerGas).toBe("0x12a05f200");
}); });
+18
View File
@@ -599,6 +599,24 @@ describe("verifySignedTx field comparison", () => {
expect(e.message).toContain("1.0 ETH"); expect(e.message).toContain("1.0 ETH");
} }
}); });
// The fee is in the native currency of the network the transaction is
// for, whether its chain id is the hex string the background prepares
// or the number ethers parses from a signed transaction.
test.each([
["0x1", "ETH"],
[1n, "ETH"],
["0xaa36a7", "SepoliaETH"],
[11155111n, "SepoliaETH"],
])("the refusal on chain %p names %s", (chainId, nativeCurrency) => {
expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow(
"up to 3000.0 " +
nativeCurrency +
", which is more than the 1.0 " +
nativeCurrency +
" this wallet",
);
});
}); });
test("every field mismatch is a refusal, not a warning", async () => { test("every field mismatch is a refusal, not a warning", async () => {
+18
View File
@@ -52,6 +52,7 @@ const {
resetRenderedViews, resetRenderedViews,
} = require("../src/popup/viewRouter"); } = require("../src/popup/viewRouter");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { restorableStack } = require("../src/shared/persistedState");
const ADDRESS = "0x1111111111111111111111111111111111111111"; const ADDRESS = "0x1111111111111111111111111111111111111111";
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
@@ -209,6 +210,23 @@ describe("Back onto a view the reopened popup never rendered", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/481. Settings, the recovery
// phrase or delete wallet screen, the gear, then a reopen: the restored stack
// is cut at the screen the gear left, which leaves Settings under the Settings
// the popup reopens onto.
describe("Back from Settings reopened over its own entry", () => {
test.each(["show-phrase", "delete-wallet-confirm"])(
"goes to the screen under it after leaving %s",
(left) => {
const stored = ["main", "settings", left];
reopenedOn("settings", restorableStack(stored, "settings"));
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
},
);
});
// The guards are restoreView()'s, so a popped view whose backing data is // The guards are restoreView()'s, so a popped view whose backing data is
// gone lands on Home rather than on an empty template. // gone lands on Home rather than on an empty template.
describe("Back onto a view whose backing data is gone", () => { describe("Back onto a view whose backing data is gone", () => {
+190 -2
View File
@@ -267,9 +267,22 @@ function loadBackground(options) {
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(opts.lastFocused || null),
create: (options2, cb) => { create: (options2, cb) => {
created.push(options2); // A copy, as the browser takes it at the call: the background
// reuses the object when it asks a second time.
created.push({ ...options2 });
// A browser that refuses any position it is given, as Chrome
// does for one it judges too far off screen.
if (opts.refusePosition && options2.left !== undefined) {
global.chrome.runtime.lastError = {
message:
"Invalid value for bounds. Bounds must be at least 50% within visible screen space.",
};
cb(undefined);
global.chrome.runtime.lastError = null;
return;
}
// A browser that answers with no window at all. The approval // A browser that answers with no window at all. The approval
// then has no window it can ever be answered in. // then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length }); cb(opts.noWindow ? undefined : { id: created.length });
@@ -977,6 +990,86 @@ describe("one connection and one signature approval per site at a time", () => {
expect(first.result()).toEqual({ result: [signer.address] }); expect(first.result()).toEqual({ result: [signer.address] });
}); });
// The user rejects a signature request from another site, which is
// connected already. Answering any approval sets the toolbar popup back to
// the wallet.
async function rejectSignatureFromAnotherSite(bg) {
const sign = bg.requestSign(undefined, ORIGIN);
await settle();
bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: sign.id(),
approved: false,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
}
test("a toolbar prompt is shown again after another site's signature request is answered", async () => {
const bg = loadBackground({ actionPopup: true });
const first = bg.requestSite();
await settle();
const id = first.id();
// Its popup closed without connecting.
await rejectSignatureFromAnotherSite(bg);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(2);
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html?approval=" + id,
);
});
test("a prompt in a window is not opened again when the site asks again", async () => {
const bg = loadBackground({ actionPopup: true });
// The browser will not open the toolbar popup, so the prompt goes to a
// window of its own.
bg.openPopup.mockImplementation(() =>
Promise.reject(new Error("no toolbar popup")),
);
bg.requestSite();
await settle();
expect(bg.created).toHaveLength(1);
await rejectSignatureFromAnotherSite(bg);
expect(bg.created).toHaveLength(2);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(1);
expect(bg.created).toHaveLength(2);
});
test("a prompt in a connected toolbar popup is not opened again when the site asks again", async () => {
const bg = loadBackground({ actionPopup: true });
const first = bg.requestSite();
await settle();
// The popup is open and showing the prompt.
bg.connectApproval(first.id());
await rejectSignatureFromAnotherSite(bg);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(1);
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
});
test("another site's connection request is not held up", async () => { test("another site's connection request is not held up", async () => {
const bg = loadBackground(); const bg = loadBackground();
bg.requestSite(); bg.requestSite();
@@ -2155,6 +2248,27 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// The prompt is decided before the toolbar popup raised for it has
// loaded; that popup is torn down and openPopup() rejects only after.
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
const bg = loadBackground({ actionPopup: true });
const opening = deferred();
bg.openPopup.mockImplementation(() => opening.promise);
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
opening.reject(new Error("the toolbar popup closed before it loaded"));
await settle();
expect(bg.created).toHaveLength(0);
});
// The port carries a decision now, so it carries the sender check the // The port carries a decision now, so it carries the sender check the
// one-off message used to carry. A content script that guessed an // one-off message used to carry. A content script that guessed an
// approval id must not be able to connect the site it is running on. // approval id must not be able to connect the site it is running on.
@@ -2615,3 +2729,77 @@ describe("removing a site in Settings disconnects it", () => {
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] }); expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
}); });
}); });
// An approval window still open is often the last focused window, and headless
// Chrome reports one as 1280x720. Centred on that, the next approval window
// lands where the browser refuses to create it, and its request failed with no
// window at all (https://git.eeqj.de/sneak/AutistMask/issues/290).
describe("where an approval window opens", () => {
test("centred on the browser window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "normal",
left: 0,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "popup",
left: 440,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
});
test("placed by the browser when it refuses the centred position", async () => {
const bg = loadBackground({
refusePosition: true,
lastFocused: {
type: "normal",
left: 1500,
top: 900,
width: 400,
height: 300,
},
});
const sign = bg.requestSign();
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
// The request waits on the second window rather than failing:
// closing that window is refusing the prompt.
expect(sign.result()).toBeNull();
bg.closeWindow(2);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
});
+8 -1
View File
@@ -5,7 +5,7 @@
// //
// The browser half of the same claim — that a real Chrome renders that // The browser half of the same claim — that a real Chrome renders that
// string as text and puts no iframe in the popup DOM — is in // string as text and puts no iframe in the popup DOM — is in
// tests/e2e/run.js. This half runs inside the 20-second make test cap. // tests/e2e/run.js. This half runs inside the 60-second make test cap.
"use strict"; "use strict";
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
expect(html).toContain("<span>1.5000</span>"); expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"'); expect(html).toContain('data-token="0xabc"');
}); });
// formatUsd() writes a value under a cent as "< $0.01".
test("escapes the USD value along with the symbol", () => {
const html = balanceLine("USDC", 0.001, 1, null);
expect(html).toContain("&lt; $0.01");
expect(html).not.toContain("< $0.01");
});
}); });
+95
View File
@@ -0,0 +1,95 @@
// The balance refresh, and the address scan after a wallet is created, do not
// report a request the popup's own closing cancelled, and still report one
// that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/218,
// https://git.eeqj.de/sneak/AutistMask/issues/475).
//
// In the popup a cancelled fetch() fails with the same "Failed to fetch" as a
// server that cannot be reached, so every request here fails that way, and
// only the signal the popup aborts on pagehide tells the two cases apart.
const { FetchRequest } = require("ethers");
const { refreshBalances, scanForAddresses } = require("../src/shared/balances");
const {
generateMnemonic,
hdWalletFromMnemonic,
} = require("../src/shared/wallet");
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const ADDRESS = "0x1111111111111111111111111111111111111111";
const realFetch = globalThis.fetch;
let logged;
beforeEach(() => {
logged = [];
jest.spyOn(console, "error").mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
const failedToFetch = async () => {
throw new TypeError("Failed to fetch");
};
// The RPC calls (ETH balance, ENS name, and the scan's balance and
// transaction count) and the explorer request (token balances) all fail
// the same way.
FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch);
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
jest.restoreAllMocks();
});
function refresh(signal) {
const wallets = [{ addresses: [{ address: ADDRESS }] }];
return refreshBalances(
wallets,
RPC_URL,
EXPLORER_URL,
[],
"mainnet",
signal,
);
}
test("a failure while the popup is open is reported", async () => {
await refresh(new AbortController().signal);
for (const label of [
"ETH balance failed",
"ENS reverse failed",
"fetchTokenBalances failed: Failed to fetch",
]) {
expect(logged.some((line) => line.includes(label))).toBe(true);
}
});
test("a failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await refresh(pageClosed.signal);
expect(logged).toEqual([]);
});
function scan(signal) {
// What the scan found is logged at info level, which is not under test.
jest.spyOn(console, "log").mockImplementation(() => {});
const { xpub } = hdWalletFromMnemonic(generateMnemonic());
return scanForAddresses(xpub, RPC_URL, "mainnet", signal);
}
test("a scan failure while the popup is open is reported", async () => {
await scan(new AbortController().signal);
expect(
logged.some((line) => line.includes("scanForAddresses check failed")),
).toBe(true);
});
test("a scan failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await scan(pageClosed.signal);
expect(logged).toEqual([]);
});
+104
View File
@@ -0,0 +1,104 @@
// `make dev` runs `node build.js --watch`
// (https://git.eeqj.de/sneak/AutistMask/issues/332). These drive build.js's
// watch() over a temp directory with a stand-in for build(), so nothing here
// builds or writes dist/.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { watch } = require("../build");
let dir;
let watchers = [];
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-watch-"));
fs.mkdirSync(path.join(dir, "nested"));
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
for (const watcher of watchers) watcher.close();
watchers = [];
fs.rmSync(dir, { recursive: true, force: true });
jest.restoreAllMocks();
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Wait until `condition()` holds; fail the test if it has not within 3s.
async function until(condition) {
const deadline = Date.now() + 3000;
while (!condition()) {
if (Date.now() > deadline) throw new Error("timed out waiting");
await sleep(10);
}
}
// Save the way many editors do: write a new copy, then rename it over the
// original, so the file at that path is a different one afterwards.
function saveByRename(file, contents) {
fs.writeFileSync(`${file}.tmp`, contents);
fs.renameSync(`${file}.tmp`, file);
}
test("builds at start, then once per change to a file in a subdirectory", async () => {
const file = path.join(dir, "nested", "a.js");
fs.writeFileSync(file, "1");
let builds = 0;
watchers = watch([dir], async () => {
builds++;
});
await until(() => builds === 1);
fs.writeFileSync(file, "2");
await until(() => builds === 2);
await sleep(300);
expect(builds).toBe(2);
});
test("keeps seeing a file that is saved by renaming a new copy over it", async () => {
const file = path.join(dir, "nested", "a.js");
fs.writeFileSync(file, "1");
let builds = 0;
watchers = watch([dir], async () => {
builds++;
});
await until(() => builds === 1);
saveByRename(file, "2");
await until(() => builds === 2);
saveByRename(file, "3");
await until(() => builds === 3);
});
test("a failed build is reported and watching carries on", async () => {
let builds = 0;
watchers = watch([dir], async () => {
builds++;
if (builds === 1) throw new Error("unexpected token");
});
await until(() => console.error.mock.calls.length === 1);
expect(console.error).toHaveBeenCalledWith(
"Build failed: unexpected token",
);
fs.writeFileSync(path.join(dir, "a.js"), "1");
await until(() => builds === 2);
});
test("a change made while a build runs causes one more build after it", async () => {
let builds = 0;
watchers = watch([dir], async () => {
builds++;
if (builds === 1) {
fs.writeFileSync(path.join(dir, "a.js"), "1");
await sleep(200);
}
});
await until(() => builds === 2);
await sleep(300);
expect(builds).toBe(2);
});
+2 -2
View File
@@ -301,7 +301,7 @@ describe.each([
test("a contract creation's row says so, with no colour dot and no address line", async () => { test("a contract creation's row says so, with no colour dot and no address line", async () => {
const html = await rowsFor(historyTx("")); const html = await rowsFor(historyTx(""));
expect(html).toContain(SENTENCE); expect(html).toContain(SENTENCE);
expect(html).not.toContain("background:"); expect(html).not.toContain("bg-[#");
expect(html).not.toContain("am-address"); expect(html).not.toContain("am-address");
expect(html).not.toContain("undefined"); expect(html).not.toContain("undefined");
}); });
@@ -309,7 +309,7 @@ describe.each([
test("a transaction with a recipient shows its colour dot and address", async () => { test("a transaction with a recipient shows its colour dot and address", async () => {
const html = await rowsFor(historyTx(RECIPIENT)); const html = await rowsFor(historyTx(RECIPIENT));
expectAddressLine(html); expectAddressLine(html);
expect(html).toContain("background:#"); expect(html).toContain("bg-[#");
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`); expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
}); });
}); });
+53
View File
@@ -0,0 +1,53 @@
// What debugFetch writes to the console in debug mode.
//
// RPC providers put the API key in the URL's path or query string, and the
// debug log used to print the whole URL and request body, so turning debug
// mode on wrote the key to the console
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
// the request.
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
setRuntimeDebug(false);
jest.restoreAllMocks();
});
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
setRuntimeDebug(true);
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
await debugFetch(
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_chainId",
params: [],
}),
},
);
const logged = consoleLog.mock.calls.flat().join(" ");
expect(logged).not.toContain("PATHKEY123");
expect(logged).not.toContain("QUERYTOKEN456");
expect(logged).toContain("https://rpc.example.invalid");
expect(logged).toContain("eth_chainId");
});
test("the origin leaves out a user name and password in the URL", () => {
expect(
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
).toBe("https://rpc.example.invalid");
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
"wss://rpc.example.invalid:8546",
);
});
+113 -1
View File
@@ -46,6 +46,9 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// U+200B, built from its code point so that it can be seen in this file.
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
// ------------------------------------------------------------ DOM stub // ------------------------------------------------------------ DOM stub
function makeElement(id) { function makeElement(id) {
@@ -250,7 +253,7 @@ describe("reaching the screen", () => {
const { decryptWithPassword } = require("../src/shared/vault"); const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("nope")); decryptWithPassword.mockRejectedValue(new Error("nope"));
await click("btn-delete-wallet-confirm"); await click("btn-delete-wallet-confirm");
expect(node("delete-wallet-flash").textContent).toBe( expect(node("delete-wallet-password-error").textContent).toBe(
"That password is incorrect. Please try again.", "That password is incorrect. Please try again.",
); );
}); });
@@ -342,6 +345,72 @@ describe("the typed confirmation", () => {
"secret-three", "secret-three",
]); ]);
}); });
// A name of only spaces compares as nothing, and so does an empty
// field. Typing nothing must still delete nothing.
test.each(["", " "])(
"typing %j deletes nothing when the name is only spaces",
async (typedValue) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = " ";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = typedValue;
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets).toHaveLength(3);
expect(await persistedWallets(storage)).toHaveLength(3);
},
);
// A name that shows nothing would leave nothing on screen to type
// back, so the screen names the wallet by its position instead, and
// that is what the user types.
test.each([
["spaces", " "],
["a zero-width space", ZERO_WIDTH_SPACE],
])(
"a name of only %s is shown and typed back as Wallet 2",
async (_label, storedName) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = storedName;
await openLostPassword(deleteWallet, 1);
expect(node("delete-wallet-lost-name").textContent).toBe(
"Wallet 2",
);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
},
);
// A zero-width space paints nothing, so "My", a zero-width space and
// "Wallet" reads as "MyWallet", and that is all the user can type. HTML
// does not collapse it the way it collapses spaces, so it has to be
// removed explicitly.
test("a zero-width space inside the name is not part of it", async () => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = "My" + ZERO_WIDTH_SPACE + "Wallet";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "MyWallet";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
});
}); });
describe("deleting without the password", () => { describe("deleting without the password", () => {
@@ -546,3 +615,46 @@ describe("the password route's confirm button", () => {
]); ]);
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/480: leaving either delete
// screen drops its wallet selection, so Back onto one showed a screen whose
// button could only answer "No wallet selected for deletion." Taking the
// screen off the stack leaves Settings under Settings, and Back must not land
// there either (https://git.eeqj.de/sneak/AutistMask/issues/481).
describe("Back from Settings after leaving by the settings gear", () => {
test("goes past the delete screen to the screen under Settings", () => {
const { helpers, deleteWallet, state } = load();
deleteWallet.show(1);
// The settings gear: push the current view, then show Settings.
helpers.pushCurrentView();
helpers.showView("settings");
expect(state.viewStack).toEqual(["main", "settings"]);
helpers.goBack();
expect(state.currentView).toBe("main");
});
test("goes past the lost-password screen to the screen under Settings", async () => {
const { helpers, deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
// The settings gear: push the current view, then show Settings.
helpers.pushCurrentView();
helpers.showView("settings");
expect(state.viewStack).toEqual(["main", "settings"]);
helpers.goBack();
expect(state.currentView).toBe("main");
});
// The lost-password screen's own Back is "Back returns to the delete
// screen with its wallet still chosen", above.
test("the delete screen's own Back leaves the rest of the stack alone", async () => {
const { deleteWallet, state } = load();
deleteWallet.show(1);
await click("btn-delete-wallet-back");
expect(state.currentView).toBe("settings");
expect(state.viewStack).toEqual(["main"]);
});
});
+1 -1
View File
@@ -105,7 +105,7 @@ describe("the flash line the message is shown in", () => {
// "a rejected dust threshold shifts no layout (#233)" and "an over-long // "a rejected dust threshold shifts no layout (#233)" and "an over-long
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by // flash message keeps to one line (#252)" in tests/e2e/run.js, run by
// make test-e2e. They are not in make check because REPO_POLICIES.md // make test-e2e. They are not in make check because REPO_POLICIES.md
// caps make test at 20 seconds and a browser suite does not fit. // caps make test at 60 seconds and a browser suite does not fit.
test("reserves its height in the markup", () => { test("reserves its height in the markup", () => {
const flashLine = POPUP_HTML.match( const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/, /<div\s+id="flash-msg"\s+class="([^"]*)"/,
+138 -53
View File
@@ -8,7 +8,7 @@
// node tests/e2e/firefox/run.js [dist/firefox] // node tests/e2e/firefox/run.js [dist/firefox]
// //
// Deliberately not part of script/check, and deliberately not named // Deliberately not part of script/check, and deliberately not named
// *.test.js: REPO_POLICIES.md caps make test at 20 seconds and a browser // *.test.js: REPO_POLICIES.md caps make test at 60 seconds and a browser
// suite does not fit. // suite does not fit.
// //
// This shares no driver layer with the Chrome suite in tests/e2e/, and the // This shares no driver layer with the Chrome suite in tests/e2e/, and the
@@ -46,6 +46,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { isDeepStrictEqual } = require("util");
const { const {
Transaction, Transaction,
@@ -62,6 +63,10 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp"); const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network"); const { STUB_COUNTERPARTY } = require("../network");
const {
STATE_SCHEMA_VERSION,
stateProblem,
} = require("../../../src/shared/stateSchema");
const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html"; const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
@@ -108,6 +113,137 @@ step("popup loads and reaches the welcome view", async (env) => {
assert(title === "AutistMask", "unexpected popup title: " + title); assert(title === "AutistMask", "unexpected popup title: " + title);
}); });
// The same check as the Chrome suite's (#418), so both browsers are held to
// the same font.
step("the popup is drawn in the monospace font it declares", async (env) => {
const font = await env.driver.execute(
"return getComputedStyle(document.body).fontFamily;",
);
// --font-mono in src/popup/styles/main.css, as the browser writes it out.
assert(
font ===
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
"the popup is drawn in " + font + ", not in --font-mono",
);
});
// The recovery screen (#361): the Chrome suite's four cases, run before any
// wallet exists for the same reason. With no wallet nothing saves on a timer,
// so no save can write a good record over the unreadable one. The last of them
// erases it, which leaves the popup on Welcome for wallet creation.
// A profile a newer build wrote: a wallet with its encrypted secret, under a
// schema version this build refuses to read.
const UNREADABLE_RECORD = {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-written-by-a-newer-build",
encryptedSecret: "ciphertext-written-by-a-newer-build",
nextIndex: 1,
addresses: [{ address: STUB_COUNTERPARTY }],
},
],
};
// The whole stored record, read on the popup page.
function storedRecord(d) {
return d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`,
);
}
step(
"an unreadable stored record opens the popup on the recovery screen",
async (env) => {
const d = env.driver;
// The popup the first step opened saves once, as it shows Welcome.
// Stored before that save lands, the record would be written over.
const deadline = Date.now() + 15000;
for (;;) {
const stored = await storedRecord(d);
if (stored && stored.currentView === "welcome") break;
assert(
Date.now() < deadline,
"the Welcome screen's save never landed: " +
JSON.stringify(stored),
);
await sleep(100);
}
await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`,
[UNREADABLE_RECORD],
);
await d.navigate(POPUP_URL);
await d.waitVisible("#view-state-recovery");
const problem = await d.text("#state-recovery-problem");
assert(
problem === stateProblem(UNREADABLE_RECORD),
"the recovery screen names the problem as " +
JSON.stringify(problem),
);
},
);
step("Export Saved Data shows the stored record verbatim", async (env) => {
const d = env.driver;
await d.click("#btn-state-recovery-export");
await d.waitVisible("#state-recovery-blob");
const exported = await d.value("#state-recovery-blob");
assert(exported !== "", "Export Saved Data left the text box empty");
assert(
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
"the text box does not hold the stored record: " + exported,
);
});
step("a near-miss confirmation phrase erases nothing", async (env) => {
const d = env.driver;
await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
await d.click("#btn-state-recovery-reset");
await d.waitFor(
"the refusal on the error line",
`return document.getElementById("state-recovery-flash").textContent ===
"Type ERASE MY WALLET to confirm. Nothing was erased.";`,
);
const stored = await storedRecord(d);
assert(
isDeepStrictEqual(stored, UNREADABLE_RECORD),
"the stored record changed: " + JSON.stringify(stored),
);
});
step(
"the exact confirmation phrase erases the record and reloads into Welcome",
async (env) => {
const d = env.driver;
try {
await d.fill("#state-recovery-reset-input", "ERASE MY WALLET");
await d.click("#btn-state-recovery-reset");
// Welcome is the proof of the erase: the record still stored
// would put the recovery screen up again, and its wallet would
// open Home.
await d.waitVisible("#view-welcome");
} finally {
// Whatever failed in these four steps, wallet creation starts
// from Welcome. The record left stored would fail every step
// after this.
if (!(await d.isVisible("#view-welcome"))) {
await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.remove("autistmask").then(() => done());`,
);
await d.navigate(POPUP_URL);
}
}
},
);
step("wallet creation through the UI reaches the main view", async (env) => { step("wallet creation through the UI reaches the main view", async (env) => {
const d = env.driver; const d = env.driver;
await d.click("#btn-welcome-add"); await d.click("#btn-welcome-add");
@@ -718,37 +854,6 @@ step(
// ------------------------------------------------------------- runner // ------------------------------------------------------------- runner
// Uncaught extension errors that are known, tracked and deliberately
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
// every entry names the issue that will delete it, and every occurrence is
// still printed, so tolerating one is visible in the log rather than silent.
// This is the only concession in an otherwise zero-tolerance policy.
const ALLOWED_ERRORS = [
{
// The site-connection buttons in src/popup/views/approval.js send
// their decision and call window.close() on the next line. Firefox's
// BaseContext.wrapPromise reports, through Cu.reportError, any
// extension-API promise that settles after its context unloaded —
// whether or not the caller attached a handler, so notify()'s catch
// cannot suppress it.
//
// Pre-existing, and not introduced by the promise shim: the send was
// already unawaited, and this suite is merely the first thing to
// drive that window on Firefox. It is the same teardown ordering as
// the issue below, whose fix — making the outcome independent of when
// the popup closes — removes this entry with it.
pattern: /Promise (?:resolved|rejected) after context unloaded/,
source: /\/src\/popup\/index\.js$/,
issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
},
];
function allowedFor(e) {
return ALLOWED_ERRORS.find(
(a) => a.pattern.test(e.msg) && a.source.test(e.src),
);
}
function formatError(e) { function formatError(e) {
return ( return (
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")" e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
@@ -865,20 +970,6 @@ async function main() {
installFailure = null; installFailure = null;
} }
// Tolerated errors are set aside, never dropped: each one is
// printed with the issue that keeps it on the list, so the
// concession stays in the run output.
const tolerated = found.filter((e) => allowedFor(e));
found = found.filter((e) => !allowedFor(e));
for (const e of tolerated) {
console.log(
"# tolerated (" +
allowedFor(e).issue +
"): " +
formatError(e),
);
}
// Any uncaught error from an extension source fails the step // Any uncaught error from an extension source fails the step
// that provoked it, whether or not its assertions passed. // that provoked it, whether or not its assertions passed.
if (!failure && found.length > 0) { if (!failure && found.length > 0) {
@@ -903,13 +994,7 @@ async function main() {
// blamed on any one step, but they are still reported and they // blamed on any one step, but they are still reported and they
// still fail the run. // still fail the run.
await sleep(1000); await sleep(1000);
const trailingAll = await errors.take(); const trailing = await errors.take();
for (const e of trailingAll.filter((x) => allowedFor(x))) {
console.log(
"# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
);
}
const trailing = trailingAll.filter((e) => !allowedFor(e));
console.log( console.log(
"# " + "# " +
(steps.length - failed) + (steps.length - failed) +
+1 -1
View File
@@ -4,7 +4,7 @@
// //
// This runs inside the pinned Playwright container; see script/test-e2e. // This runs inside the pinned Playwright container; see script/test-e2e.
// It is deliberately NOT part of make check — REPO_POLICIES.md caps // It is deliberately NOT part of make check — REPO_POLICIES.md caps
// make test at 20 seconds and a browser suite does not fit. // make test at 60 seconds and a browser suite does not fit.
"use strict"; "use strict";
+62 -26
View File
@@ -22,7 +22,7 @@
"use strict"; "use strict";
const { Transaction } = require("ethers"); const { AbiCoder, Transaction } = require("ethers");
// Fictional ERC-20 used to seed the transaction-detail test. The symbol // Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or // must not collide with any entry in src/shared/tokenList.js, or
@@ -244,26 +244,39 @@ function latestBlock() {
}; };
} }
// keccak("decimals()")[0:4]. // keccak("decimals()")[0:4], and the same for symbol() and name().
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
// Every eth_call still answers with a zero word except decimals() on the // Every eth_call still answers with a zero word except decimals(), symbol()
// stub token, which the wallet reads back at signing time to compare with // and name() on the stub token. The wallet reads decimals() back at signing
// the scale the confirmation screen rendered (issue #305). // time to compare with the scale the confirmation screen rendered (issue
// #305). Adding the token by its contract address reads all three (issue
// #295); symbol() and name() answer what the explorer reports for it.
// //
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
// Blockscout, which is exactly the disagreement the wallet must refuse to // Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options // sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has // object the route was registered with — after the confirmation screen has
// been built — without re-registering anything. // been built — without re-registering anything. Only null or undefined means
// no override: 0 is a token with no decimal places, and is answered as one.
function ethCallResult(req, opts) { function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { if (to !== STUB_TOKEN.address) return ZERO_WORD;
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals); if (data.startsWith(SELECTOR_DECIMALS)) {
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
}
const abi = AbiCoder.defaultAbiCoder();
if (data.startsWith(SELECTOR_SYMBOL)) {
return abi.encode(["string"], [tokenObject(opts).symbol]);
}
if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]);
} }
return ZERO_WORD; return ZERO_WORD;
} }
@@ -405,27 +418,23 @@ function sleep(ms) {
const HOLD_POLL_MS = 25; const HOLD_POLL_MS = 25;
const HOLD_MAX_MS = 30000; const HOLD_MAX_MS = 30000;
// Hold a gas estimate open for as long as the test asks. // Hold a reply open for as long as the test asks: until opts[name] is false.
// //
// opts.holdGasEstimate is read here rather than captured, so a test flips it // The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
// on the same options object the route was registered with — the same // here rather than captured, so a test flips it on the same options object the
// pattern as seedTokenTransfer. This is the only way to observe the // route was registered with — the same pattern as seedTokenTransfer. This is
// confirmation screen while its estimate is genuinely in flight; sampling // the only way to observe a screen while its request is genuinely in flight;
// the screen and hoping to win a race against the network would assert // sampling the screen and hoping to win a race against the network would
// nothing on a slow machine. // assert nothing on a slow machine.
// //
// It never gives up quietly. A hold that outlives the bound is reported like // It never gives up quietly. A hold that outlives the bound is reported like
// any other harness fault, because a "pending" state that stopped being // any other harness fault, because a "pending" state that stopped being
// pending on its own is a green assertion about the wrong screen. // pending on its own is a green assertion about the wrong screen.
async function awaitRelease(opts, report) { async function awaitRelease(opts, name, report) {
const started = Date.now(); const started = Date.now();
while (opts.holdGasEstimate) { while (opts[name]) {
if (Date.now() - started > HOLD_MAX_MS) { if (Date.now() - started > HOLD_MAX_MS) {
report( report(name + " was never released after " + HOLD_MAX_MS + "ms");
"held gas estimate was never released after " +
HOLD_MAX_MS +
"ms",
);
return; return;
} }
await sleep(HOLD_POLL_MS); await sleep(HOLD_POLL_MS);
@@ -447,6 +456,16 @@ function rpcReply(req, opts, report) {
return Object.assign(envelope, { result: ethCallResult(req, opts) }); return Object.assign(envelope, { result: ethCallResult(req, opts) });
} }
if (req.method === "eth_getTransactionReceipt") { if (req.method === "eth_getTransactionReceipt") {
// A lookup that fails, which the wait screen counts differently from
// one that answers "not mined yet" (README.md, WaitTx).
if (opts.failReceiptLookup) {
return Object.assign(envelope, {
error: {
code: -32000,
message: "e2e fixture: receipt lookup failed",
},
});
}
const hash = Array.isArray(req.params) ? req.params[0] : null; const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, { return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null, result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
@@ -539,7 +558,10 @@ async function handleRpc(route, postData, opts, report) {
return route.abort(); return route.abort();
} }
if (batch.some((req) => req.method === "eth_estimateGas")) { if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, report); await awaitRelease(opts, "holdGasEstimate", report);
}
if (batch.some((req) => req.method === "eth_getTransactionCount")) {
await awaitRelease(opts, "holdTransactionCount", report);
} }
const replies = batch.map((req) => rpcReply(req, opts, report)); const replies = batch.map((req) => rpcReply(req, opts, report));
@@ -595,16 +617,25 @@ function traceEnabled(raw) {
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an * @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
* eth_getTransactionCount until this is cleared again.
* @param {boolean} [opts.holdBlockscout] hold every Blockscout request until
* this is cleared again.
* @param {boolean} [opts.failTransactionList] fail every request for an
* address's transaction list as a network error; read at request time.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for * @param {number|string|null} [opts.tokenDecimalsOverride] the scale
* the stub token, in place of the value Blockscout reports for it. This is * decimals() answers for the stub token, in place of the value Blockscout
* the token that lies about its scale; read at request time. * reports for it; null for none, while 0 is a scale like any other. This
* is the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as * @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose * the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time. * symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a * @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves. * confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
@@ -667,7 +698,12 @@ async function installNetworkStubs(ctx, opts) {
// Blockscout v2 // Blockscout v2
if (p.includes("/api/v2/")) { if (p.includes("/api/v2/")) {
await awaitRelease(opts, "holdBlockscout", report);
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
// Aborted rather than answered with an error status: to the
// page this is a server that cannot be reached, and fetch()
// rejects with "Failed to fetch".
if (opts.failTransactionList) return route.abort();
const addr = blockscoutAddress(p); const addr = blockscoutAddress(p);
return jsonResponse(route, { return jsonResponse(route, {
items: items:
+1347 -415
View File
File diff suppressed because it is too large Load Diff
+74 -6
View File
@@ -58,7 +58,20 @@ function makeElement(id, withParent) {
remove: () => {}, remove: () => {},
querySelectorAll: () => [], querySelectorAll: () => [],
}; };
el.parentElement = withParent ? makeElement(id + "-parent", false) : null; el.parentElement = null;
if (withParent) {
// As in a browser, replacing the parent's contents takes this
// element out of the document: getElementById no longer finds it.
el.parentElement = makeElement(id + "-parent", false);
let html = "";
Object.defineProperty(el.parentElement, "innerHTML", {
get: () => html,
set: (value) => {
html = value;
el.removed = true;
},
});
}
return el; return el;
} }
@@ -70,7 +83,8 @@ function makeDocument() {
// is the state a non-debug, non-testnet popup is in. // is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null; if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id, true)); if (!els.has(id)) els.set(id, makeElement(id, true));
return els.get(id); const el = els.get(id);
return el.removed ? null : el;
}, },
createElement: () => makeElement("created", false), createElement: () => makeElement("created", false),
addEventListener: () => {}, addEventListener: () => {},
@@ -193,7 +207,7 @@ describe("a decrypt still running when the screen is left", () => {
}); });
// Same hole on the failure path: a wrong-password error written after // Same hole on the failure path: a wrong-password error written after
// the wipe would restore the flash line on a screen the user has left. // the wipe would restore the error line on a screen the user has left.
test("never writes the failure message either", async () => { test("never writes the failure message either", async () => {
const { helpers, vault, exportPrivkey } = load(); const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0); exportPrivkey.show(0, 0);
@@ -203,8 +217,10 @@ describe("a decrypt still running when the screen is left", () => {
reveal.reject(new Error("decryption failed")); reveal.reject(new Error("decryption failed"));
await reveal.pending; await reveal.pending;
expect(node("export-privkey-flash").textContent).toBe(""); expect(node("export-privkey-password-error").textContent).toBe("");
expect(node("export-privkey-flash").style.visibility).toBe("hidden"); expect(node("export-privkey-password-error").style.visibility).toBe(
"hidden",
);
}); });
}); });
@@ -246,7 +262,7 @@ describe("a reveal that is not interrupted", () => {
await reveal.pending; await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe( expect(node("export-privkey-password-error").textContent).toBe(
"That password is incorrect. Please try again.", "That password is incorrect. Please try again.",
); );
}); });
@@ -300,6 +316,58 @@ describe("leaving the screen after the key is on it", () => {
}); });
}); });
describe("opening the screen again in the same popup session", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/460: show() found the
// address line through an element inside it, which its own rendering
// deleted, so the second open threw before it navigated.
test("shows it for the address chosen the second time", async () => {
const { state, exportPrivkey } = load();
exportPrivkey.show(0, 0);
await click("btn-export-privkey-back");
expect(state.currentView).toBe("address");
exportPrivkey.show(0, 1);
expect(state.currentView).toBe(VIEW);
expect(node("export-privkey-title").textContent).toBe(
"Wallet 1 — Address 2",
);
expect(node("export-privkey-address").innerHTML).toContain(
"0x" + "22".repeat(20),
);
});
});
describe("Back from Settings after leaving by the settings gear", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the
// address selection, so Back onto this screen showed a password prompt
// that could only answer "No address is selected."
test("goes to the address screen it was opened from", () => {
const { helpers, state, exportPrivkey } = load();
state.viewStack = ["main"];
exportPrivkey.show(0, 0);
// The settings gear: push the current view, then show Settings.
helpers.pushCurrentView();
helpers.showView("settings");
helpers.goBack();
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
test("its own Back button leaves the rest of the stack alone", async () => {
const { state, exportPrivkey } = load();
state.viewStack = ["main"];
exportPrivkey.show(0, 0);
await click("btn-export-privkey-back");
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
});
describe("views the popup may reopen onto", () => { describe("views the popup may reopen onto", () => {
// Restoring onto this screen would put a private key on display with no // Restoring onto this screen would put a private key on display with no
// password prompt in front of it, on a popup reopened by accident. // password prompt in front of it, on a popup reopened by accident.
+1
View File
@@ -91,6 +91,7 @@ describe.each([
require("../src/popup/views/" + view).init({ require("../src/popup/views/" + view).init({
doRefreshAndRender: () => {}, doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
}); });
element(field).value = ADDRESS; element(field).value = ADDRESS;
await element(button).listeners.click(); await element(button).listeners.click();
+33
View File
@@ -57,6 +57,39 @@ describe("parseHoldersCount", () => {
expect(parseHoldersCount("many")).toBeNull(); expect(parseHoldersCount("many")).toBeNull();
expect(parseHoldersCount(NaN)).toBeNull(); expect(parseHoldersCount(NaN)).toBeNull();
}); });
// Each of these starts with a digit, so reading only the leading digits
// would turn it into a small reported count, and a small count is
// exactly what hides a token as spam (issue #251).
test.each(["1,000", "0x10", "1e3", "12 holders"])(
"%p is not read in part: it is unknown",
(raw) => {
expect(parseHoldersCount(raw)).toBeNull();
},
);
test("a negative count is unknown", () => {
expect(parseHoldersCount("-5")).toBeNull();
expect(parseHoldersCount(-5)).toBeNull();
});
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
// string of digits would come back rounded, and a long enough one as
// Infinity, which would pass every holder-count floor.
test("a count too large for a number to hold exactly is unknown", () => {
expect(parseHoldersCount("9007199254740993")).toBeNull();
expect(parseHoldersCount("9".repeat(400))).toBeNull();
expect(parseHoldersCount(2 ** 53)).toBeNull();
});
test("the largest count a number holds exactly still parses", () => {
expect(parseHoldersCount("9007199254740991")).toBe(
Number.MAX_SAFE_INTEGER,
);
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
Number.MAX_SAFE_INTEGER,
);
});
}); });
describe("isLowHolderCount", () => { describe("isLowHolderCount", () => {
+11
View File
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
expect(displaySymbol(exact)).toBe(exact); expect(displaySymbol(exact)).toBe(exact);
}); });
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
// Cutting between them leaves half of one, which renders as U+FFFD.
test("counts an emoji as one character and never cuts one in half", () => {
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH),
);
expect(displaySymbol("🚀".repeat(20))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
);
});
test("substitutes a placeholder for an absent symbol", () => { test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL); expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL); expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
+33
View File
@@ -0,0 +1,33 @@
// The toolbar icons in icons/ are drawn by script/lib/icons.js (`make icons`).
// Each committed file must hold exactly the image it draws, the same IHDR and
// every pixel, so the drawing and the files cannot drift apart. The compressed
// bytes are not compared: the committed files were compressed by stock zlib,
// and node's bundled zlib compresses the same pixels differently.
const fs = require("fs");
const path = require("path");
const { icons } = require("../manifest/chrome.json");
const { drawIcon, decodePng } = require("../script/lib/icons");
describe("toolbar icons", () => {
test.each(Object.entries(icons))(
"the %spx icon %s holds the image script/lib/icons.js draws",
(size, file) => {
const side = Number(size);
const committed = decodePng(
fs.readFileSync(path.join(__dirname, "..", file)),
);
const drawn = decodePng(drawIcon(side));
expect(committed.header).toEqual(drawn.header);
// Each row is its filter type byte, then 4 bytes per pixel.
expect(drawn.rows.length).toBe(side * (side * 4 + 1));
expect(committed.rows.length).toBe(drawn.rows.length);
// The offset of the first byte that differs, not a diff of all.
expect(
committed.rows.findIndex((byte, i) => byte !== drawn.rows[i]),
).toBe(-1);
},
);
});
+7 -8
View File
@@ -21,15 +21,14 @@
// escaping in src/shared/html.js is the primary fix; default-src is what // escaping in src/shared/html.js is the primary fix; default-src is what
// stops the next escape that slips from reaching the network. // stops the next escape that slips from reaching the network.
// //
// Every directive below is pinned exactly, because each of the four // And for #328: style-src is 'self' alone, so the browser refuses every
// style="..." attribute in the popup's markup, including one an escape lets
// through. The popup styles with classes; script setting element.style is
// not affected.
//
// Every directive below is pinned exactly, because each of the three
// loosenings is load-bearing and none of them may grow: // loosenings is load-bearing and none of them may grow:
// //
// style-src 'unsafe-inline' src/popup/index.html and the view helpers
// use style="..." attributes throughout, which
// CSP blocks without it. Chrome enforces this
// on attributes, not just <style> blocks, and
// Firefox has never implemented style-src-attr,
// so there is no narrower spelling available.
// img-src data: blockies are data: PNGs assigned to img.src. // img-src data: blockies are data: PNGs assigned to img.src.
// connect-src https: http: the RPC endpoint is user-configurable, and a // connect-src https: http: the RPC endpoint is user-configurable, and a
// local node over http://127.0.0.1 is a // local node over http://127.0.0.1 is a
@@ -58,7 +57,7 @@ const EXPECTED_DIRECTIVES = {
"default-src": ["'self'"], "default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval'"], "script-src": ["'self'", "'wasm-unsafe-eval'"],
"object-src": ["'self'"], "object-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"], "style-src": ["'self'"],
"img-src": ["'self'", "data:"], "img-src": ["'self'", "data:"],
"connect-src": ["'self'", "http:", "https:"], "connect-src": ["'self'", "http:", "https:"],
"frame-src": ["'none'"], "frame-src": ["'none'"],
+461
View File
@@ -0,0 +1,461 @@
// The native token's label on the screens that show a native amount.
//
// src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet
// and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a
// hardcoded "ETH", so on Sepolia the balance, the value and the fee all read
// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted
// on both networks, through the real Send, confirmation and approval screens,
// with only the node and the DOM stubbed. So is that a token cannot pass for
// the native token by reporting its label, and that a transaction's figures
// carry its own network's label when another network is active.
"use strict";
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async lookupAddress() {
return null;
}
// 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getTransactionReceipt() {
return { blockNumber: 21000000 };
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// Signing a send succeeds without a key, and sending answers with a hash.
jest.mock("../src/shared/vault", () => ({
...jest.requireActual("../src/shared/vault"),
decryptWithPassword: async () => "secret",
}));
jest.mock("../src/shared/wallet", () => ({
...jest.requireActual("../src/shared/wallet"),
getSignerForAddress: () => ({
connect: () => ({
populateTransaction: async (request) => request,
sendTransaction: async () => ({ hash: "0x" + "3".repeat(64) }),
}),
}),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The approval the background hands the approval screen. Set per test.
let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub(),
runtime: {
connect: () => ({
postMessage() {},
disconnect() {},
onDisconnect: { addListener() {} },
}),
sendMessage(message, callback) {
callback(
message.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: undefined,
);
},
},
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// Views reach for .parentElement to hide whole sections.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
const txStatus = require("../src/popup/views/txStatus");
const { balanceLinesForAddress } = require("../src/popup/views/helpers");
const { filterTransactions } = require("../src/shared/transactions");
const { debugFetch } = require("../src/shared/log");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token contract that is not in the bundled token list.
const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
function text(id) {
return global.document.getElementById(id).textContent;
}
// Press Review on the Send screen for a native send of `amount`, and show the
// confirmation screen it leads to with its fee estimate settled.
async function confirmSend(amount) {
let txInfo = null;
send.init({ showConfirmTx: (info) => (txInfo = info) });
state.selectedToken = "ETH";
global.document.getElementById("send-to").value = RECIPIENT;
global.document.getElementById("send-amount").value = amount;
await global.document
.getElementById("btn-send-review")
.handlers.get("click")();
confirmTx.show(txInfo);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The approval screen for a dApp transaction sending 0.01 of the native token
// with 21000 gas at up to 20 gwei, on the network with `chainId`.
async function approveTx(chainId) {
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
approvedFrom: HOLDER,
approvedTx: {
to: RECIPIENT,
value: "10000000000000000",
data: "0x",
chainId,
gasLimit: "21000",
maxFeePerGas: "20000000000",
nonce: 0,
},
};
await approval.show("1");
}
describe.each([
["mainnet", "ETH"],
["sepolia", "SepoliaETH"],
])("on %s the native token reads %s", (networkId, symbol) => {
beforeEach(() => {
elements.clear();
clearPrices();
state.networkId = networkId;
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: HOLDER, balance: "1.5" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
});
test("the balance", async () => {
const addr = state.wallets[0].addresses[0];
expect(balanceLinesForAddress(addr, [], false)).toContain(
`<span>${symbol}</span><span>1.5000</span>`,
);
state.selectedToken = "ETH";
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol);
await confirmSend("0.1");
expect(text("confirm-balance")).toBe("1.5000 " + symbol);
});
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
test("the fee", async () => {
await confirmSend("0.1");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol);
expect(text("confirm-fee-reserve")).toBe(
"up to 0.0004 " + symbol + " reserved",
);
expect(text("confirm-gas-error")).toContain(
"You do not have enough " + symbol + " to pay the network fee",
);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
});
test("the contract-recipient warning", async () => {
await confirmSend("0.1");
expect(text("confirm-contract-warning")).toContain(
"Sending " + symbol + " or tokens directly to a contract",
);
});
// A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was.
test.each(["ETH", symbol])(
"a token claiming %s is dropped from the history and the Send selector",
(claim) => {
const result = filterTransactions(
[
{
hash: "0x" + "1".repeat(64),
symbol: claim,
contractAddress: TOKEN_CONTRACT,
holders: 900000,
valueGwei: null,
isContractCall: false,
},
],
{ hideSpoofedSymbols: true },
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]);
send.renderSendTokenSelect({
address: HOLDER,
tokenBalances: [
{
address: TOKEN_CONTRACT,
symbol: claim,
decimals: 18,
balance: "5",
holders: 900000,
},
],
});
expect(
global.document.getElementById("send-token").children,
).toEqual([]);
},
);
// The detail screen tells the two apart by the token contract, which only
// a token transfer has, so a token reporting the native label still reads
// as a token transfer.
test("the transaction detail screen's type line", () => {
const entry = {
hash: "0x" + "2".repeat(64),
from: RECIPIENT,
to: HOLDER,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "received",
directionLabel: "Received",
chainId: NETWORKS[networkId].chainId,
};
transactionDetail.show({ ...entry, contractAddress: null });
expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer");
transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT });
expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer");
});
test("the insufficient-balance error", async () => {
await confirmSend("2");
expect(
global.document.getElementById("confirm-errors").innerHTML,
).toContain(
"You have 1.5000 " +
symbol +
" but are trying to send 2 " +
symbol +
".",
);
});
});
// A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. A site can switch the
// active network after its transaction is prepared and back before it is
// signed, and a popup opened after a switch shows a sent or listed transaction
// again. The wallet's balances follow the active network; these do not.
describe.each([
["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"],
])(
"a %s transaction shown with %s active reads %s",
(txNetworkId, activeNetworkId, symbol) => {
const chainId = NETWORKS[txNetworkId].chainId;
const hash = "0x" + "3".repeat(64);
beforeEach(() => {
elements.clear();
clearPrices();
state.networkId = activeNetworkId;
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: HOLDER, balance: "1.5" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender() {} });
});
afterEach(() => {
txStatus.endWait();
});
test("the approval screen's value and fee", async () => {
await approveTx(chainId);
expect(text("approve-tx-network")).toBe(NETWORKS[txNetworkId].name);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
});
test("the wait, success and error screens", async () => {
const txInfo = {
from: HOLDER,
to: RECIPIENT,
amount: "0.0100",
token: "ETH",
tokenSymbol: null,
chainId,
};
txStatus.showWait(txInfo, hash);
expect(text("wait-tx-summary")).toBe("0.0100 " + symbol);
txStatus.showError(txInfo, hash, "Failed.");
expect(text("error-tx-summary")).toBe("0.0100 " + symbol);
// A later popup resumes the wait, and the receipt is there.
state.viewData = {
pendingWait: { txInfo, hash, broadcastTime: Date.now() },
};
txStatus.restoreWait();
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
expect(text("success-tx-summary")).toBe("0.0100 " + symbol);
});
// Sent from the Send screen on the transaction's network, then
// resumed by a popup that opens after the active network changed.
test("the wait screen after a send", async () => {
state.networkId = txNetworkId;
await confirmSend("0.1");
confirmTx.init({});
global.document.getElementById("confirm-tx-password").value = "pw";
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
debugFetch.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: async () => ({ fee: { value: "21000000000000" } }),
}));
transactionDetail.show({
hash,
from: RECIPIENT,
to: HOLDER,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "received",
directionLabel: "Received",
contractAddress: null,
chainId,
});
expect(text("tx-detail-type")).toBe(
"Native " + symbol + " Transfer",
);
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
expect(
global.document.getElementById("tx-detail-fee").innerHTML,
).toContain("0.000021 " + symbol);
});
},
);
+159
View File
@@ -0,0 +1,159 @@
// Every screen that asks for a password shows a password error the same way:
// through showError() and hideError() in src/popup/views/helpers.js, in a
// fixed-height error line below the password field, and never in the flash
// line at the top of the popup
// (https://git.eeqj.de/sneak/AutistMask/issues/493). These boot the real popup
// over src/popup/index.html, so each error line has to exist in the markup,
// and check that the error appears in it and clears again.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "That password is incorrect. Please try again.";
afterEach(() => {
cleanupPopup();
});
// The error line as the user sees it.
function errorLine(page, id) {
return {
inMarkup: page.document.authoredIds.has(id),
text: page.text(id),
visibility: page.node(id).style.visibility,
};
}
function shown(text) {
return { inMarkup: true, text, visibility: "visible" };
}
const cleared = { inMarkup: true, text: "", visibility: "hidden" };
describe("the add wallet screen", () => {
const ERROR = "add-wallet-password-error";
// First run: Welcome, "Add wallet", then the die for a valid phrase.
async function openAddWallet() {
const page = await bootPopup(undefined);
await page.click("btn-welcome-add");
await page.click("btn-generate-phrase");
return page;
}
function setPasswords(page, password, confirm) {
page.node("add-wallet-password").value = password;
page.node("add-wallet-password-confirm").value = confirm;
}
test("shows a password problem below the password fields, and clears it on the next press", async () => {
const page = await openAddWallet();
setPasswords(page, "short", "short");
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(
shown("Password must be at least 12 characters."),
);
expect(page.text("flash-msg")).toBe("");
// The password is fixed and the phrase emptied: the password error
// goes, and the phrase problem is still reported in the flash line.
setPasswords(page, PASSWORD, PASSWORD);
page.node("wallet-mnemonic").value = "";
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(cleared);
expect(page.text("flash-msg")).toBe(
"Enter a recovery phrase, or press the die.",
);
// Leaving clears the flash line and stops its timer, which would
// otherwise fire after this page is gone.
await page.click("btn-add-wallet-back");
});
test("clears the error when the screen is shown again", async () => {
const page = await openAddWallet();
setPasswords(page, PASSWORD, PASSWORD + " typo");
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(
shown("Passwords do not match."),
);
await page.click("btn-add-wallet-back");
await page.click("btn-welcome-add");
expect(errorLine(page, ERROR)).toEqual(cleared);
});
});
describe.each([
{
screen: "the private key export screen",
open: () => require("../src/popup/views/exportPrivkey").show(0, 0),
field: "export-privkey-password",
button: "btn-export-privkey-confirm",
error: "export-privkey-password-error",
},
{
screen: "the recovery phrase screen",
open: () => require("../src/popup/views/showPhrase").show(0),
field: "show-phrase-password",
button: "btn-show-phrase-reveal",
error: "show-phrase-password-error",
},
{
screen: "the delete wallet screen",
open: () => require("../src/popup/views/deleteWallet").show(0),
field: "delete-wallet-password",
button: "btn-delete-wallet-confirm",
error: "delete-wallet-password-error",
},
])("$screen", ({ open, field, button, error }) => {
// Opens the screen and enters a password the vault rejects.
async function failedAttempt() {
const page = await bootPopup(unversionedValidProfile());
open();
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
page.node(field).value = "not the password";
await page.click(button);
return { page, decryptWithPassword };
}
test("shows a wrong password below the password field", async () => {
const { page } = await failedAttempt();
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
});
test("clears the error while the next password is checked", async () => {
const { page, decryptWithPassword } = await failedAttempt();
let rejectDecrypt;
decryptWithPassword.mockReturnValue(
new Promise((resolve, reject) => {
rejectDecrypt = reject;
}),
);
page.node(field).value = "another guess";
const pressed = page.click(button);
await page.settle();
expect(errorLine(page, error)).toEqual(cleared);
rejectDecrypt(new Error("wrong password"));
await pressed;
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
});
test("clears the error when the screen is shown again", async () => {
const { page } = await failedAttempt();
open();
expect(errorLine(page, error)).toEqual(cleared);
});
});
+166 -48
View File
@@ -49,22 +49,37 @@
// every path a stored record takes, and the difference is the whole of what // every path a stored record takes, and the difference is the whole of what
// this file does not cover: // this file does not cover:
// //
// - Only the values in the table, in the SLOT arrangement below: four value // - Only the values in the table, in the SLOT arrangement below. On the
// combinations per view, not the product of twelve fields. A dereference // restore path the twelve fields the router does not read are corrupted
// reached only under a pairing no slot produces is not driven at all. // together, every field on the same slot, so a view gets four value
// - Only what a stored record reaches by ITSELF. A view only forward // combinations of them, not their product. A branch entered only when one
// navigation opens, and anything behind a click, is not driven. // of them is truthy and another falsy is reached only where the falsy
// - Nothing about the paths a HEALTHY profile takes, which is most of the // slot happens to pair a field that cannot be falsy with one that is.
// popup. This file is a floor under one defect class, not a proof about // Each field the router reads is corrupted alone, over an otherwise
// the renderers. // well-formed record.
// - Only what a stored record reaches by ITSELF, as the boot renders it. A
// view only forward navigation opens, anything behind a click, and
// anything behind a timer (bootPopup() records every interval, and this
// file never runs one) is not driven.
// - Of the paths a HEALTHY profile takes, only its boot onto each
// restorable view ("the base profile the sweep corrupts" below). The rest
// of the popup is not covered: this file is a floor under one defect
// class, not a proof about the renderers.
// //
// Within that boundary it is unconditional: if one of these boots leaves the // Within that boundary it is unconditional: if a boot that corrupts a field
// popup unhealthy or off the view it stored, this file goes red — including // leaves the popup unhealthy, this file goes red — including when it takes
// when it takes two corrupted fields at once, because the verdict is the // two corrupted fields at once, because the verdict is the combined boot
// combined boot itself and the per-field re-boot below can only decorate the // itself and the per-field re-boot below can only decorate the message. That
// message. That last part is the one thing an earlier version got wrong: it // last part is the one thing an earlier version got wrong: it asserted on the
// asserted on the per-field list, so an observed dead popup that no single // per-field list, so an observed dead popup that no single field reproduced
// field reproduced was reported green. // was reported green.
//
// Where the popup lands is held for some of those boots and not others. The
// combined boot must land on the view it stored, and each `hostileRestore`
// value must land on its view, or fall back to Home, as its entry declares.
// The boots in "a hostile routing value restoring onto" are held to health
// alone, because a value in a field the router reads legitimately changes
// which view renders; so are the boots onto Home, which store no view.
// //
// Booting every field separately at every value would be several hundred boots // Booting every field separately at every value would be several hundred boots
// and most of the suite's budget; this is forty-four. Widening it further is // and most of the suite's budget; this is forty-four. Widening it further is
@@ -128,7 +143,11 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// list short and pointed. `floorOnly` is extra values checked against the // list short and pointed. `floorOnly` is extra values checked against the
// floor alone, which is pure and free. `hostileRestore` is extra values driven // floor alone, which is pure and free. `hostileRestore` is extra values driven
// through the restore path only, for a value that means nothing until a // through the restore path only, for a value that means nothing until a
// particular branch's gate has let it past. // particular branch's gate has let it past. Each of its entries names the
// `views` it is driven onto and declares whether the boot lands on them
// (`restored: true`) or falls back to Home (`restored: false`), so a value
// written for one renderer cannot stop reaching it unnoticed. A value driven
// onto every restorable view is written with everyRestorableView() below.
// //
// `falsy` is the other POLARITY of a swept field, driven for the same reason. // `falsy` is the other POLARITY of a swept field, driven for the same reason.
// It is not a value src/ never writes — for three of these fields it is the // It is not a value src/ never writes — for three of these fields it is the
@@ -139,6 +158,23 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// falsy value stored under that field comes back TRUTHY from the floor, so no // falsy value stored under that field comes back TRUTHY from the floor, so no
// `!state.x` branch is reachable from a stored record at all. // `!state.x` branch is reachable from a stored record at all.
// The `hostileRestore` entries for a value driven onto every restorable view:
// it falls back to Home on the views listed in `fallsBackOn` and lands on every
// other one, so a view added to RESTORABLE_VIEWS is driven, and expected to
// land, without editing the row.
function everyRestorableView(value, fallsBackOn) {
return [
{ value, views: fallsBackOn, restored: false },
{
value,
views: [...RESTORABLE_VIEWS].filter(
(view) => !fallsBackOn.includes(view),
),
restored: true,
},
];
}
const CONTRACT = [ const CONTRACT = [
{ {
field: "wallets", field: "wallets",
@@ -280,28 +316,38 @@ const CONTRACT = [
kind: KIND.SCALAR, kind: KIND.SCALAR,
// The prototype members are the whole point: `wallets["map"]` is // The prototype members are the whole point: `wallets["map"]` is
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and // TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
// `.addresses[…]` throws. A stale INTEGER is the safe case. // `.addresses[…]` throws. A stale INTEGER is the safe case and has to
hostile: ["map", "__proto__", { a: 1 }], // stay so. 5 is one, out of range for the one wallet in the profile,
// and it is also this field's truthy polarity: every other value here
// comes back from the floor as null.
hostile: ["map", "__proto__", { a: 1 }, 5],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull, holds: isIndexOrNull,
// SCALAR, and swept anyway: the restore path is precisely why this // SCALAR, and swept anyway: the restore path is precisely why this
// field gained a floor, so the sweep is the regression guard on it. // field gained a floor, so the sweep is the regression guard on it.
alsoSweep: true, alsoSweep: true,
routes: true, routes: true,
// A stale INTEGER index, which reaches the restore path by a different // Comes back from the floor as null, which hasValidAddress() reads as
// route from the prototype members above — falsy or out of range // nothing selected: the popup falls back to Home on the five views
// rather than truthy — and has to keep being the safe case. // that need an address, and lands on every other one.
hostileRestore: [{ value: "length" }, { value: 5 }], hostileRestore: everyRestorableView("length", [
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
]),
}, },
{ {
field: "selectedAddress", field: "selectedAddress",
kind: KIND.SCALAR, kind: KIND.SCALAR,
hostile: ["map", "__proto__", { a: 1 }], // 5 for the same reason as in selectedWallet: a stale index, and the
// one value here still truthy after the floor.
hostile: ["map", "__proto__", { a: 1 }, 5],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull, holds: isIndexOrNull,
alsoSweep: true, alsoSweep: true,
routes: true, routes: true,
hostileRestore: [{ value: 5 }],
}, },
{ {
field: "currentView", field: "currentView",
@@ -325,7 +371,9 @@ const CONTRACT = [
// container shapes below onto every restorable view; hostileRestore // container shapes below onto every restorable view; hostileRestore
// adds the records that PASS a branch's gate and then hand its // adds the records that PASS a branch's gate and then hand its
// renderer something it dereferences, which is where the entries are // renderer something it dereferences, which is where the entries are
// actually decided. // actually decided. The guard refuses each single-view record below,
// so each is declared to fall back to Home: one that started landing
// would be reaching the renderer it was written against.
hostile: [42, "notarecord", { a: 1 }, [1, 2]], hostile: [42, "notarecord", { a: 1 }, [1, 2]],
// `structuredClone(saved.viewData || {})`: the container is never falsy // `structuredClone(saved.viewData || {})`: the container is never falsy
// in state whatever was stored, so no `!state.viewData` branch exists to // in state whatever was stored, so no `!state.viewData` branch exists to
@@ -334,11 +382,20 @@ const CONTRACT = [
hostileRestore: [ hostileRestore: [
// success-tx passes on `data.hash`, and renderSuccess() then calls // success-tx passes on `data.hash`, and renderSuccess() then calls
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase(). // toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
{ value: { hash: "0x1" }, views: ["success-tx"] }, {
{ value: { hash: "0x1", to: 42 }, views: ["success-tx"] }, value: { hash: "0x1" },
views: ["success-tx"],
restored: false,
},
{
value: { hash: "0x1", to: 42 },
views: ["success-tx"],
restored: false,
},
{ {
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } }, value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
views: ["success-tx"], views: ["success-tx"],
restored: false,
}, },
{ {
value: { value: {
@@ -347,12 +404,25 @@ const CONTRACT = [
decoded: { details: [{ address: 42 }] }, decoded: { details: [{ address: 42 }] },
}, },
views: ["success-tx"], views: ["success-tx"],
restored: false,
}, },
// error-tx passes on `data.message`, same dereference. // error-tx passes on `data.message`, same dereference.
{ value: { message: "boom" }, views: ["error-tx"] }, {
{ value: { message: "boom", to: 42 }, views: ["error-tx"] }, value: { message: "boom" },
views: ["error-tx"],
restored: false,
},
{
value: { message: "boom", to: 42 },
views: ["error-tx"],
restored: false,
},
// transaction passes on `data.tx`. // transaction passes on `data.tx`.
{ value: { tx: { hash: "0x1" } }, views: ["transaction"] }, {
value: { tx: { hash: "0x1" } },
views: ["transaction"],
restored: false,
},
{ {
value: { value: {
tx: { tx: {
@@ -363,9 +433,14 @@ const CONTRACT = [
}, },
}, },
views: ["transaction"], views: ["transaction"],
restored: false,
}, },
// confirm-tx passes on `data.pendingTx`. // confirm-tx passes on `data.pendingTx`.
{ value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] }, {
value: { pendingTx: { amount: "1" } },
views: ["confirm-tx"],
restored: false,
},
{ {
value: { value: {
pendingTx: { pendingTx: {
@@ -376,6 +451,7 @@ const CONTRACT = [
}, },
}, },
views: ["confirm-tx"], views: ["confirm-tx"],
restored: false,
}, },
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked // wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the // the fields below it since it was written, and this is the
@@ -388,20 +464,29 @@ const CONTRACT = [
}, },
}, },
views: ["wait-tx"], views: ["wait-tx"],
restored: false,
}, },
// A record that passes EVERY branch's gate at once, driven onto // A record that passes EVERY branch's gate at once, driven onto
// every restorable view: a branch a view does not read must stay // every restorable view: a branch a view does not read must stay
// one it does not read, and each renderer must survive the fields // one it does not read. The five views with a viewData branch
// another branch left behind. // refuse it; every other one renders it, and must survive the
{ // fields every branch left behind.
value: { ...everyRestorableView(
{
hash: "0x1", hash: "0x1",
message: "boom", message: "boom",
tx: { hash: "0x1" }, tx: { hash: "0x1" },
pendingTx: { amount: "1" }, pendingTx: { amount: "1" },
pendingWait: { hash: "0x1" }, pendingWait: { hash: "0x1" },
}, },
}, [
"confirm-tx",
"transaction",
"wait-tx",
"success-tx",
"error-tx",
],
),
], ],
}, },
{ {
@@ -583,6 +668,11 @@ const HEALTHY = { errors: [], blank: false };
// set is all-truthy by construction, so without a falsy slot a dereference // set is all-truthy by construction, so without a falsy slot a dereference
// behind `if (!state.x)` is never reached on the boot that corrupts x — the // behind `if (!state.x)` is never reached on the boot that corrupts x — the
// same falsy-collapse blind spot the fields below were floored for. // same falsy-collapse blind spot the fields below were floored for.
//
// Only `hostile` and `falsy` values count. Those go through the sweep below,
// which covers every restorable view; a `hostileRestore` entry is driven onto
// only the views it names, so a polarity it alone supplied might reach a single
// renderer.
describe("both polarities of every swept field are driven", () => { describe("both polarities of every swept field are driven", () => {
const FALSY_STORED = [0, "", false, null]; const FALSY_STORED = [0, "", false, null];
const floored = (field, value) => const floored = (field, value) =>
@@ -606,10 +696,9 @@ describe("both polarities of every swept field are driven", () => {
test(`${row.field}: truthy and falsy`, () => { test(`${row.field}: truthy and falsy`, () => {
// What the boots below actually drive, floored the way a renderer // What the boots below actually drive, floored the way a renderer
// sees it — not what the row says it drives. // sees it — not what the row says it drives.
const driven = [ const driven = sweptValues(row).map((value) =>
...sweptValues(row), floored(row.field, value),
...(row.hostileRestore || []).map((entry) => entry.value), );
].map((value) => floored(row.field, value));
expect({ expect({
truthy: driven.some((value) => Boolean(value)), truthy: driven.some((value) => Boolean(value)),
@@ -722,6 +811,28 @@ describe("the base profile the sweep corrupts", () => {
} }
}); });
// Home, AddressDetail and AddressToken load their transactions inside a catch
// that only logs, so a boot that fails there still renders the view and passes
// the tests above while none of that code runs.
describe("the base profile loads transactions", () => {
for (const view of ["main", "address", "address-token"]) {
test(`on ${view} without logging a failure`, async () => {
const consoleError = jest.spyOn(console, "error");
try {
await bootPopup(restoringOnto(view));
const failures = consoleError.mock.calls
.map((args) => args.join(" "))
.filter((line) =>
/loadHomeTxs failed|loadTransactions failed/.test(line),
);
expect(failures).toEqual([]);
} finally {
consoleError.mockRestore();
}
});
}
});
// A field the ROUTER itself reads — the two it gates on and the two // A field the ROUTER itself reads — the two it gates on and the two
// hasValidAddress() indexes with. A hostile value in one of these legitimately // hasValidAddress() indexes with. A hostile value in one of these legitimately
// changes which view renders, so each gets its own boot per view and is held // changes which view renders, so each gets its own boot per view and is held
@@ -843,20 +954,27 @@ describe("every field the router does not read, corrupted at once, onto", () =>
// The values that only mean something on the restore path: a viewData that // The values that only mean something on the restore path: a viewData that
// PASSES a branch's gate and then hands its renderer something dereferenced, // PASSES a branch's gate and then hands its renderer something dereferenced,
// and the index values whose route through hasValidAddress() differs from the // and a selectedWallet the floor turns into nothing selected. Each boot must
// row's own hostile set. // throw nothing and show exactly the view its entry says it lands on: its own,
// or Home, so a value written for one renderer cannot stop reaching it and
// still pass.
describe("a restore-only hostile value onto", () => { describe("a restore-only hostile value onto", () => {
for (const row of CONTRACT) { for (const row of CONTRACT) {
for (const entry of row.hostileRestore || []) { for (const entry of row.hostileRestore || []) {
for (const view of entry.views || RESTORABLE_VIEWS) { for (const view of entry.views) {
test(`${view}: ${row.field} = ${JSON.stringify( test(`${view}: ${row.field} = ${JSON.stringify(
entry.value, entry.value,
)}`, async () => { )}`, async () => {
await expect( const env = await bootPopup(
bootHealth( restoringOnto(view, { [row.field]: entry.value }),
restoringOnto(view, { [row.field]: entry.value }), );
), expect({
).resolves.toEqual(HEALTHY); errors: env.pageErrors,
visible: env.visibleViews(),
}).toEqual({
errors: [],
visible: [entry.restored ? view : "main"],
});
}); });
} }
} }
+229
View File
@@ -0,0 +1,229 @@
// The signature prompt shows a personal message as the bytes that are signed
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
// text it decodes to with control characters, line and paragraph separators
// and characters that paint nothing marked rather than obeyed, markup shown as
// text, laid out in byte order, and a message that is not hex as plain text
// that cannot be signed.
//
// Driven against a minimal DOM stub in the shape
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { hexlify, toUtf8Bytes } = require("ethers");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const FROM = "0x0000000000000000000000000000000000000a11";
// Built from their code points so that this file holds none of them.
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
const HANGUL_FILLER = String.fromCodePoint(0x3164);
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
function makeElement(id) {
const classes = new Set();
return {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the signature prompt for a personal_sign of `message`, the way the
// popup does: it asks the background for the approval and show() draws it.
async function openPersonalSign(message) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
signParams: { method: "personal_sign", message, from: FROM },
});
},
};
approval.init({});
await approval.show(1);
}
// The message box's markup as the text a reader sees: tags dropped.
function shownMessage() {
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
}
beforeEach(() => {
state.wallets = [];
state.activeAddress = FROM;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("a right-to-left override is marked, so the text reads in byte order", async () => {
// Obeyed, the override shows "0001" as "1000".
const text =
"Pay " +
RIGHT_TO_LEFT_OVERRIDE +
"0001" +
POP_DIRECTIONAL_FORMATTING +
" ETH";
await openPersonalSign(hexlify(toUtf8Bytes(text)));
const html = node("approve-sign-message").innerHTML;
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
});
test("a zero-width character is marked", async () => {
await openPersonalSign(
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
);
expect(node("approve-sign-message").innerHTML).not.toContain(
ZERO_WIDTH_SPACE,
);
expect(shownMessage()).toBe("payU+200Bpal.com");
});
test("variation selectors and a Hangul filler are marked", async () => {
// Each paints nothing, so a page could hide bytes after "Sign in".
await openPersonalSign(
hexlify(
toUtf8Bytes(
"Sign in" +
VARIATION_SELECTOR_1 +
VARIATION_SELECTOR_17 +
HANGUL_FILLER,
),
),
);
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
});
test("the message is laid out in byte order", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
expect(
node("approve-sign-message").classList.contains("am-byte-order"),
).toBe(true);
});
test("a control character other than a line feed is marked", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
expect(shownMessage()).toBe("aU+0000bU+0009c");
});
test("line and paragraph separators are marked", async () => {
// Left in the text, a paragraph separator would end the byte-order
// layout for everything after it.
await openPersonalSign(
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
);
const html = node("approve-sign-message").innerHTML;
expect(html).not.toContain(LINE_SEPARATOR);
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
expect(shownMessage()).toBe("aU+2028bU+2029");
});
test("a line feed is shown as a line break", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
});
// The message box is written as HTML, so a site's markup has to arrive there
// escaped, as the text it is.
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
test.each([
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
["a message that is not hex", MARKUP],
])("markup in %s is shown as text, not as markup", async (_, message) => {
await openPersonalSign(message);
expect(node("approve-sign-message").innerHTML).toBe(
"&lt;b&gt;x&lt;/b&gt;&lt;img src=x onerror=alert(1)&gt;",
);
});
test("the raw hex is shown alongside the text", async () => {
await openPersonalSign("0x48656c6c6f");
expect(shownMessage()).toBe("Hello");
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
false,
);
});
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
await openPersonalSign("0X48656C6C6F");
expect(shownMessage()).toBe("Hello");
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
expect(node("btn-approve-sign").disabled).toBe(false);
});
test("bytes that are not text are shown only as hex", async () => {
await openPersonalSign("0xff00");
expect(node("approve-sign-message").textContent).toBe(
"This message is not text.",
);
expect(node("approve-sign-hex").textContent).toBe("0xff00");
});
test("a message that is not hex is shown as text and cannot be signed", async () => {
await openPersonalSign("Hello world");
expect(shownMessage()).toBe("Hello world");
expect(node("approve-sign-error").textContent).toBe(
"This message is plain text, not hex, so it cannot be signed.",
);
expect(node("btn-approve-sign").disabled).toBe(true);
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
true,
);
});

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