Compare commits

...
Author SHA1 Message Date
sneak eca2d15249 fix: Back from Settings no longer lands on a secret screen left by the gear (closes #461)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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-06 21:10:42 +00:00
clawbot ad1b4f0557 fix: a popup reload no longer logs the requests it cancels on most screens (closes #475)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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
clawbot 1144fdb71b harden: key remembered site permissions by full origin (closes #402)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s
allowedSites and deniedSites held the bare hostname, so a grant to
https://dapp.example also authorised http://dapp.example and every port
on that host, and the connection, transaction and signature prompts
named only the hostname. Both lists now store and match the full origin
(scheme://host[:port]), the key the connections approved without
Remember already used. The prompts, the Settings site lists and
AUTISTMASK_REMOVE_SITE use the origin too. Entries saved by hostname
are not migrated (pre-1.0): they match no site.

Model: opus-5-5
2026-10-04 18:09:04 +02:00
clawbot f24b5bca19 harden: take a request's origin from the frame that sent it (closes #407)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
Where the browser gives no sender.origin (Firefox before 126), the
background credited a page's request to the tab's page, so a frame from
another site counted as the site embedding it, and with no tab it used
an origin the page wrote into the message. It now uses the origin of
sender.url, the frame that sent the message, and refuses the request
with code 4100 when the browser gives neither. The content script no
longer writes an origin into the message.

Model: opus-5-5
2026-10-04 17:26:05 +02:00
clawbot 9f0e88e963 test: load the popup's libraries once per test file, not on every boot (closes #428)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Every popup boot in the tests resets jest's module registry so that src/
loads fresh, and that also reloaded ethers, libsodium-wrappers-sumo, qrcode
and ethereum-blockies-base64 each time. tests/support/popupBoot.js now loads
those four once per test file and registers them once with jest.doMock(),
which jest.resetModules() keeps, so every boot gets the same copies. No test
or assertion changed. make test takes 8-13s on the shared build host, down
from 17-25s, measured in alternating runs before and after the change.

Model: opus-5-5
2026-10-04 16:59:12 +02:00
clawbot 45f11ee920 fix: say an unknown-scale balance the same way on Send and on confirm (closes #377)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s
When two addresses' explorer reports disagree on a token's decimals, the
Send screen showed the stored figure while the confirmation screen said
the balance was unknown. One function in send.js now gives both the
balance and scale, so both read `unknown (SYMBOL)`.

The confirmation screen's fee-unknown message names its cause: for an
unknown scale it says the wallet does not know the token's decimal
places and the transaction cannot be sent, instead of asking for a
retry that cannot help. Other causes keep the old sentence.

Model: opus-5-5
2026-10-04 15:59:15 +02:00
clawbot a68f30c480 fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum (closes #283)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
decode() had no arm for Universal Router command 0x09, so the approval
screen showed no token or amount for it. It now takes the path's first
token and amountInMax as the input side, the last token and amountOut as
the output side. With such a step, the Amount figure reads "Up to
<amount>" whichever step set it, on the approval, wait, success and error
screens, unless it reads "Unlimited", as an unbounded PERMIT2_PERMIT does,
or "All available (V4 open delta)". In every swap, UNWRAP_WETH makes
Token Out ETH only when the output side is WETH, on mainnet or Sepolia, or
no step set it; otherwise the output keeps its own token and figure.
decodeV2SwapExactOut() loses its eslint-disable comment.

Model: opus-5-5
2026-10-04 15:09:09 +02:00
clawbot 43c236451f chore: run jest in three worker processes (closes #426)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The test and test:verbose scripts ran jest with one worker per CPU core,
about 47 processes and 7-8 GiB per run on the shared 48-core build host.
They now pass --maxWorkers=3. On that host the suite takes 23-29s, inside
the unchanged 30-second cap in script/test but not by much: one test
file, tests/persistedFieldContract.test.js, takes most of it. One or two
workers went past the cap, so this departs from the issue's two-process
limit. make check, the pre-commit hook and script/cibuild all reach jest
through these scripts; the timings in the script/test and Dockerfile
comments are updated to match.

Model: opus-5-5
2026-10-04 14:26:03 +02:00
130 changed files with 9823 additions and 2033 deletions
+77 -7
View File
@@ -1,7 +1,77 @@
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
# build-info stamping and the Dockerfile runs `make build`, so excluding it
# would make every built extension report commitHash "unknown".
node_modules
.DS_Store
dist
release
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# 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]
# The browser end-to-end suites, one job per browser, deliberately kept out
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
# script/cibuild is a plain `docker build .` whose Dockerfile runs
# make check, so folding a browser suite into either would blow that cap
# and slow the local fast path. Before this workflow every browser-level
# guarantee in this repo held only when a human remembered to run it.
# of the check workflow: REPO_POLICIES.md caps make test at 60 seconds and
# script/cibuild runs script/check, so folding a browser suite into either
# would blow that cap and slow the local fast path. Before this workflow
# every browser-level guarantee in this repo held only when a human
# remembered to run it.
#
# One job per browser rather than two steps in one job, so a Chrome failure
# does not hide the Firefox result.
@@ -22,11 +22,10 @@ on: [push]
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked on the measured
# flake in the dApp signing wait -- two of six runs of unmutated code on a
# loaded machine -- tracked as
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
# block. Making e2e-chrome a required check is blocked while reports of the
# Chrome suite failing under load are still open; the "In CI" section of
# README.md names them. A gate that fails at random teaches people to merge
# past red.
#
# Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the
@@ -36,6 +35,10 @@ on: [push]
jobs:
e2e-chrome:
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:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -43,6 +46,10 @@ jobs:
e2e-firefox:
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:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+31 -5
View File
@@ -11,14 +11,40 @@ Thumbs.db
.vscode/
*.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_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.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
dist/
-3
View File
@@ -1,5 +1,2 @@
node_modules/
yarn.lock
dist/
release/
.claude/
+67 -29
View File
@@ -1,42 +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
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS lint
WORKDIR /app
# Marks "already inside the lint container" for script/lint, which otherwise
# shells out to docker to build the lint stage below. Nothing outside this
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache
# and shares the runner with the rest of the build, so 30s is marginal rather
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180
# script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first
# so the bootstrap layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests
# and the build. This is also the stage script/lint builds from a host, which
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint
RUN make lint
RUN yarn run lint
RUN script/check-censored
# Test phase, same shape and for the same reason: the jest suite (its worker
# 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=test /app/package.json /dev/null
RUN make check
RUN make build
# script/bootstrap installs all prerequisites, git included. Manifests are
# 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}"
+8
View File
@@ -107,6 +107,14 @@ vendor-blocklist:
clean:
@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:
@echo "Building in watch mode..."
@yarn run build --watch 2>&1
+371 -148
View File
@@ -222,31 +222,32 @@ provide:
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (jest)
- `script/test` — build the Dockerfile's `test` phase, uncached: the jest suite,
stopped after 90 seconds and rerun verbose if it fails, then
`script/test-verify-build`
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
only prerequisite: it builds a pinned image that carries the repo and a fresh
extension build, see [End-to-End Tests](#end-to-end-tests))
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
against an image with a pinned Firefox and geckodriver, see
[End-to-End Tests](#end-to-end-tests))
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
failing on either. It never writes: `--fix` is not in this path, so
`make check` stays non-mutating. Linting runs in the container — the script
builds the Dockerfile's `lint` stage — because an ESLint result that depends
on whichever ESLint the host happens to have is not a result. Docker is
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
the same script lint in place instead of recursing.
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, check-censored, lint, and
fmt-check
- `script/lint` — build the Dockerfile's `lint` phase, uncached: ESLint
(`eslint.config.js`), `prettier --check`, then `script/check-censored`,
failing on any of them. It never writes: `--fix` is not in this path, so
`make check` stays non-mutating. Linting runs only in the container, because
an ESLint result that depends on whichever ESLint the host happens to have is
not a result, so docker is required to lint.
- `script/fmt` — format all files (writes), on the host
- `script/fmt-check` — check formatting (read-only), on the host
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
- `script/check-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
fails anywhere else. Part of `make check`, which inspects `dist/` when there
is one and says loudly when there is not; `make build` re-runs it with
fails anywhere else. Run by the `lint` phase, so part of `make check`; it
inspects `dist/` when there is one and says loudly when there is not, and the
build context of that phase never has one. `make build` re-runs it with
`--require-dist`, so a build artifact is always covered
- `script/package` — produce the release artifacts: `make build` first, so the
archives can only ever be made from a `dist/` that has been verified against
@@ -281,14 +282,23 @@ provide:
failing and a succeeding release build step, and read the `make build` and
`make build-debug` recipes back out of `make -n` to check that they pass the
mode as an argument on a scrubbed environment and wrap only the release path.
Part of `make check`; it reads no build artifacts and writes nothing under
`dist/`. The cases that depend on file permissions cannot mean anything for a
process that is not subject to them, so the harness proves its runner against
a mode-000 file before counting them, dropping to an unprivileged user when
run as root; if it cannot, it skips those cases and says so in a banner rather
than passing them.
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
Run by the `test` phase, so part of `make check`; it reads no build artifacts
and writes nothing under `dist/`. The cases that depend on file permissions
cannot mean anything for a process that is not subject to them, so the harness
proves its runner against a mode-000 file before counting them, dropping to an
unprivileged user when run as root; if it cannot, it skips those cases and
says so in a banner rather than passing them.
- `script/docker` — build the Docker image, uncached and tagged via
`script/projectname`: the `lint` and `test` phases, then `make build` in the
last stage. It passes what `git describe --tags --always --dirty` prints on
the host as the `VERSION` build argument, or `unknown` when that prints
nothing, and the last stage fails on `unknown` whenever the build context
carries `.git`. Only a build given no `VERSION` at all, such as a plain
`docker build .`, runs `git describe` on the `.git` in the build context,
which `.dockerignore` sends without its `config`, and fails if git cannot read
it
- `script/cibuild` — CI entrypoint: `script/bootstrap`, `script/check`, then the
same image build as `script/docker`
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook
@@ -306,7 +316,15 @@ The Makefile shims to those. It also carries a few targets that have no
debug build, and keeping its `dist/` on failure (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/` and `release/`
- `make dev` — build in watch mode
- `make dev` — 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/` (see
[Build Receipts](#build-receipts)): 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
## End-to-End Tests
@@ -342,6 +360,11 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
offline; unrecognised outbound requests are reported as failures rather than
silently allowed.
It also covers the StateRecovery screen, under the shipped CSP: a stored record
this build cannot read opens the popup on it, its export text box holds that
record exactly as stored, a near-miss confirmation phrase erases nothing, and
the exact one erases the record and reloads into Welcome.
It also covers the **Settings screen**, which holds the densest run of element
id lookups in the codebase and where one wrong id leaves the whole popup blank
rather than only degrading Settings: that the screen renders populated — the
@@ -371,6 +394,12 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
and six lookups in a row that fail. Each must show its own message, and Done
must lead back to the address screen. Both wait in real time, about a minute
each.
It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
@@ -381,11 +410,13 @@ handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, and the
password is required to be absent from every message the approval window sends
to the background — with the message that would carry it required to be present,
so that check cannot pass by observing nothing. That last one is the standing
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
required to return a rejection to the page rather than hang or resolve, a prompt
raised while another approval window has focus is required to open a window of
its own, and the password is required to be absent from every message the
approval window sends to the background — with the message that would carry it
required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
Two limits of that coverage, neither of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real
@@ -462,10 +493,11 @@ Chrome that ever changes this fails the run instead of passing it.
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, wallet creation through the UI, the Add Token screen, and
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
— driven through the real content script, background page and approval windows.
It covers popup load, the StateRecovery screen (the same cases as the Chrome
suite), wallet creation through the UI, the Add Token screen, and the four dApp
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
a closed approval window rejecting with EIP-1193 4001 — driven through the real
content script, background page and approval windows.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
@@ -595,7 +627,7 @@ Two limits are worth knowing, both real differences from the Chrome suite:
out, but it cannot report which requests were attempted.
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
`make test`. `REPO_POLICIES.md` caps `make test` at 60 seconds and a browser
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
pick it up either. Run them locally before changing anything under
`src/popup/views/`.
@@ -604,7 +636,7 @@ pick it up either. Run them locally before changing anything under
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
20-second `make test` cap and the local fast path are untouched. Each job is a
60-second `make test` cap and the local fast path are untouched. Each job is a
checkout and the matching `script/` entrypoint, nothing else.
Docker is the only thing either job needs from the runner, and that is not an
@@ -619,24 +651,32 @@ The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. The Chrome suite is
**measurably flaky under load** — two of six runs of unmutated code on a busy
machine lost the approval popup out from under the dApp signing wait, always in
the `#183` section, tracked as
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
has to be read before it is believed, and that flake is the blocker to ever
making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.
That is not only a statement about configuration. No report of the Chrome suite
**failing under load** is open now, but it has failed that way before, so a red
`e2e-chrome` is read before it is believed. Do not answer one with a retry
wrapper: a suite that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image
build fails, and when the browser fails to start; the Chrome harness aborts the
suite outright if its network interception is not in effect.
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way.
Measured on this repo's runner in the green runs of early October 2026, from a
warm docker cache to a cold one: `e2e-chrome` 1m44s to 4m48s, and `e2e-firefox`
31s to 4m07s. A cold cache adds three to four minutes to each job, spent
rebuilding its image: reinstalling dependencies and, for `e2e-firefox`,
installing Firefox, geckodriver and their system libraries. Both scripts now
build their image with `--no-cache`, so every run pays the cold figure. Those
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
job instead of holding the shared runner: `e2e-firefox` 15 minutes and
`e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
job that reaches its cap has hung; read it as a hang, not as a slow run to
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
copy from `sneak/prompts`, kept byte-identical.
### Element id guard (part of `make check`)
@@ -695,6 +735,7 @@ src/
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
@@ -801,13 +842,15 @@ discoverable.
on critical screens and when space is available to allow users to disambiguate
addresses visually, as a security feature.
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
styling. Tailwind is configured with a minimal monochrome palette. This keeps
the styling co-located with the markup and eliminates CSS file management. The
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
carries the copy feedback animation, and `.am-address` carries the rule that
an address never wraps. Both are invariants that hold in every place they
appear, and spelling either out as repeated utilities is how one of those
places drifts away from the rest.
styling, and no `style="..."` attributes, which the
[Content Security Policy](#content-security-policy) refuses. Tailwind is
configured with a minimal monochrome palette. This keeps the styling
co-located with the markup and eliminates CSS file management. The handful of
classes in `styles/main.css` are not styling: `.copy-flash-*` carries the copy
feedback animation, and `.am-address` carries the rule that an address never
wraps. Both are invariants that hold in every place they appear, and spelling
either out as repeated utilities is how one of those places drifts away from
the rest.
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
enough that vanilla JS with simple view switching is sufficient. A framework
would add bundle size, build complexity, and attack surface for no benefit at
@@ -841,10 +884,26 @@ something when you click it.
The same data must be formatted identically everywhere it appears. Token and ETH
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
USDT") in balance lists, transaction lists, send confirmations, and approval
screens. Timestamps include both an ISO datetime and a humanized relative age
wherever shown. If a formatting rule applies in one place, it applies in every
place. Users should never see the same value rendered differently on two
screens.
screens. A transaction's time includes both an ISO datetime and a humanized
relative age, written by `isoDate()` and `timeAgo()` in
`src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is
in UTC when the UTC Timestamps setting is on. If a formatting rule applies in
one place, it applies in every place. Users should never see the same value
rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the
active network, use the active network's. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native
ETH transfer" type line, in the contract-recipient warning or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording.
**Specific Exception — Truncation:** On some non-critical display locations, we
may truncate _a small number_ of characters from the middle of an address solely
@@ -887,7 +946,9 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
shows is a V4 exact-in `amountIn` of zero.
shows is a V4 exact-in `amountIn` of zero and
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
exact-in `amountIn` of zero.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -926,7 +987,10 @@ rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
token permission warning on the signature screen takes the same rule for its
amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`.
still shown as `Unlimited`. A source's answer counts only if it is a whole
number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
format more than 80 decimal places, so a token that reports 81 to 255 is shown
as one whose scale nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
@@ -949,6 +1013,13 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make.
The Send screen also consults every address's explorer reports, so a contract
two addresses report different `decimals` for has no scale there, and the stored
balance, formatted at one of those scales, is withdrawn with it. The Send
screen's `Current balance` and the confirmation screen's balance line then both
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below), do not always read as a number. They can
@@ -962,27 +1033,59 @@ read:
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
or `WRAP_ETH` amount that large, which is not an allowance. The router's
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
amount.
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
not necessarily all of it; the wait, success and error screens show it with
the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 exact-out step sets `Min. received`, that line shows its
`amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
readable exact-in action.
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known.
read `0.0000` when the token's scale was known. The router passes a balance
check whenever the balance is at least `minBalance`, so a zero `minBalance`
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side. The token permission warning on the signature screen has its own
amount wording, including `Unknown`; the SignApproval section below describes
it.
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
UTC, so a later deadline, such as the `uint256` maximum, reads
`After 275760-09-13 00:00:00 (no deadline in practice)` rather than leaving the
whole swap undecoded.
#### Partial USD totals
@@ -1062,15 +1165,21 @@ balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
every nonzero holding of a token it admits, however small, but a holding below
0.000001 is left out of the balance lists, the send-screen token selector, the
address total and the remove-address warning (`isBelowOneMillionth()` in
`src/shared/amountDisplay.js`). The Send and confirmation screens show it when
its token is the one being sent. Tracked tokens with a zero balance are listed
as well while "Show tracked tokens with zero balance" is on.
and the send-screen token selector, not this list. A token's holder count is
unknown when the explorer reports none, or reports anything other than a whole
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
or more, so such a token is shown only when it is on the bundled list or
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
admits, however small, but a holding below 0.000001 is left out of the balance
lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
#### Stored state and its version
@@ -1090,16 +1199,18 @@ because bumping for one would send every older install to StateRecovery for
nothing.
Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` for the popup and `getState()` for
the background. It refuses a record that is not an object, a `schemaVersion`
this build does not understand (a newer one included), a `wallets` that is not a
list of wallet records with address records in them, and a `networkId` that is
not a network in `src/shared/networks.js`. Refusing is the whole point — a
record the wallet cannot vouch for is never normalized, never written back, and
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer.
bytes, before normalization: `loadState()` and every `saveState()` for the
popup, and `getState()` for the background. It refuses a record that is not an
object, a `schemaVersion` this build does not understand (a newer one included),
a `wallets` that is not a list of wallet records with address records in them,
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
the whole point — a record the wallet cannot vouch for is never normalized,
never written back, and never half-loaded. The popup shows StateRecovery,
whether it finds the record unreadable when it opens or at a save while it is
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
spec leaves unassigned) saying the saved data cannot be read and that nothing
was signed or sent, rather than the generic `-32603` every request used to
answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated, and the floor is not the same for every field. Some are type-checked as a
@@ -1136,15 +1247,16 @@ each caught only by a reviewer re-deriving thirty fields by hand.
The `allowedSites` case is why the entry check is not optional. A stored
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
passed the gate, rendered a completely healthy popup, and then threw inside
`saveState()`'s per-hostname merge, so every save from that moment on failed and
`saveState()`'s per-origin merge, so every save from that moment on failed and
the user went on operating a wallet that was persisting nothing
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
covers — a quota, a revoked permission — and the wallet must never look healthy
while that is true. A save that fails because the stored record fails the gate,
such as one a newer build wrote, gets StateRecovery instead of the banner.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
@@ -1356,6 +1468,9 @@ view would leave a wallet one click from deletion.
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change)
- "Back" → previous screen (AddressDetail)
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
drops the address the screen was showing, so it also takes the screen off
the Back stack
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
written into the page until the password is accepted; the key is never stored
in state, and it is wiped from the page whenever the screen is left by any
@@ -1376,7 +1491,9 @@ view would leave a wallet one click from deletion.
- Send / Receive buttons
- Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project
website where known
website where known. The "Holders:" row is left out, not shown as 0, when
the token's balance-list entry has no holder count: the explorer did not
report a readable one, or the token is not in the balance list
- Token-filtered transaction list (only this token's transfers)
- **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static
@@ -1395,7 +1512,20 @@ view would leave a wallet one click from deletion.
- What to send: token dropdown (or static display with contract address when
locked from AddressToken)
- To: address or ENS name input, with an inline validation message
- Amount input with current balance display
- Amount input with current balance display, which reads
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale)
- "Max" button beside the amount input, always in place. It fills in a
token's balance, cut down to the 18 decimal places ConfirmTx accepts for a
token that has more, or for ETH the exact balance minus the network fee
reserve that ConfirmTx's balance check gates on, never the rounded balance
shown above it. The ETH fee is estimated for the recipient entered, so it
asks for a recipient first; an estimate that finishes after the screen was
left or the address, holding or recipient changed fills nothing in. Where
there is nothing to fill in, a flash message says why: the balance does
not cover the fee, the fee could not be estimated, or the token's balance
is unknown or zero. Typing in the amount makes it an ordinary amount;
changing what to send clears an amount Max filled in
- "Review" button, disabled until the recipient validates
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
@@ -1412,8 +1542,16 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses)
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
filled in is worked out again from this screen's own fee estimate when it
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance no longer covers
the fee, the amount is left as it was and the amount-plus-fee error below
blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the
@@ -1429,7 +1567,11 @@ view would leave a wallet one click from deletion.
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
estimated. The first two are mutually exclusive per transfer type, so only
the applicable one holds space
the applicable one holds space. The last names its cause: for a token
whose scale is unknown the fee can never be estimated, and it says the
wallet does not know how many decimal places the token uses and that the
transaction cannot be sent; for any other failure it asks the user to go
back and try again
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, and while the network fee
@@ -1535,7 +1677,9 @@ view would leave a wallet one click from deletion.
- "Transaction" heading, "Back" button
- Transaction hash: full hash (tap to copy) + etherscan link
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
the token reports.
- Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
@@ -1603,13 +1747,13 @@ view would leave a wallet one click from deletion.
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored.
- Allowed Sites: the hostnames remembered as allowed, under any address,
with remove buttons
- Connected Sites: the hostnames of the sites allowed without "Remember my
- Allowed Sites: the origins (scheme, host and port) remembered as allowed,
under any address, with remove buttons
- Connected Sites: the origins of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the hostnames remembered as denied, under any address, with
- Denied Sites: the origins remembered as denied, under any address, with
remove buttons
- About: project link, license, author, version, release date, and the
commit, which links to the commit in the repository
@@ -1623,10 +1767,11 @@ view would leave a wallet one click from deletion.
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token → removes it in place (no screen change)
- `[x]` on an allowed or connected site → disconnects that site, in place:
its hostname is dropped from Allowed Sites under every address, and
its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from an origin with that hostname, under any address,
and send `accountsChanged` with an empty list to the site's open tabs.
without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. The
same host under another scheme or port is another site and is left alone.
Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
@@ -1653,6 +1798,9 @@ view would leave a wallet one click from deletion.
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
screen change)
- "Back" → previous screen (Settings)
- Settings gear → **Settings**, whose "Back" never lands back on this
screen: leaving drops the wallet the screen was showing, so it also takes
the screen off the Back stack
- **Secret handling**: nothing is decrypted or written into the page until the
password is accepted; the phrase is never stored in state, and it is wiped
from the page whenever the screen is left by any route, including the Settings
@@ -1708,7 +1856,10 @@ view would leave a wallet one click from deletion.
new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever
- That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back
- The wallet's name, and a text input asking for it to be typed back. A name
that shows nothing at all (only spaces, or only characters that paint
nothing) is shown as "Wallet N", its position in the list, and that is
what is typed back.
- Error line
- "Delete This Wallet Forever" button
- **Transitions**:
@@ -1716,9 +1867,9 @@ view would leave a wallet one click from deletion.
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match) → "That is not the name
of this wallet. Type &lt;name&gt; to confirm." on the error line, nothing
deleted
- "Delete This Wallet Forever" (name does not match, or the field is empty)
→ "That is not the name of this wallet. Type &lt;name&gt; to confirm." on
the error line, nothing deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather
than parent and child: nothing is pushed on the way here, so both have
@@ -1727,8 +1878,13 @@ view would leave a wallet one click from deletion.
secret protects nobody: an attacker at the popup who wants the wallet gone can
uninstall the extension, so the only person such a gate stops is the owner who
forgot it. The typed name is a check that the user knows which wallet they are
on, not a secret, so it is matched with surrounding spaces and letter case
ignored.
on, not a secret, so it is matched as the user can see it: letter case,
surrounding spaces and repeated inner spaces are ignored, and characters that
paint nothing (format characters such as the zero-width space,
default-ignorable characters, and DELETE — the same set
`src/shared/symbolSpoof.js` strips) are removed from both sides before
comparing. An empty field, or one holding only spaces or such characters, is
refused whatever the wallet is called.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material.
@@ -1810,14 +1966,22 @@ view would leave a wallet one click from deletion.
- **When**: A website requests wallet access via `eth_requestAccounts` or
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
The background script prefers the toolbar popup (`action.openPopup()`) and
falls back to a separate popup window (`src/background/index.js`,
`requestApproval()`).
A site is its full origin, `scheme://host[:port]`, on both lists and for a
connection allowed without "Remember": a choice for `https://dapp.example`
says nothing about `http://dapp.example` or another port of that host. The
background script prefers the toolbar popup (`action.openPopup()`) and falls
back to a separate popup window (`src/background/index.js`,
`requestApproval()`). Only one exists per site at a time: a further connection
request from a site whose prompt is still unanswered is refused with EIP-1193
code `-32002` and opens no new prompt. If that prompt was in a toolbar popup
that closed before it connected, and the toolbar popup has since been set to
open something else, the refused request shows that prompt again.
- **Elements**:
- "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to connect to your wallet"
- Site origin (bold, scheme and port included) + "wants to connect to your
wallet"
- Address that will be shared (color dot + full address + etherscan link)
- "Remember my choice for this site" checkbox
- "Allow" / "Deny" buttons
@@ -1836,18 +2000,24 @@ view would leave a wallet one click from deletion.
programmatically rather than by a user gesture. The background populates the
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site. Only one transaction approval exists at a time:
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
one is unanswered is refused with EIP-1193 code `-32002` rather than being
populated at the same nonce. It opens no window and takes no nonce, and the
site can send it again once the pending one is answered.
artifact can be compared with it field for field. A nonce the site supplies is
ignored: the nonce is always the account's next nonce from the node, so a site
cannot replace one of the user's pending transactions or leave this one stuck
behind a gap. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
no window and takes no nonce, and the site can send it again once the pending
one is answered. The window is centred on the browser window the user was last
in; if that was another approval window, or the browser refuses the centred
position, the browser picks the position.
- **Elements**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to send a transaction"
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link
@@ -1873,19 +2043,32 @@ view would leave a wallet one click from deletion.
- **When**: A connected website requests a message signature via
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
TxApproval, in a separate popup window.
TxApproval, in a separate popup window. Only one exists per site at a time: a
further signature request, by any of these methods, from a site whose
signature request is still unanswered is refused with EIP-1193 code `-32002`
and opens no window.
- **Elements**:
- "Signature Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants you to sign a message"
- Site origin (bold, scheme and port included) + "wants you to sign a
message"
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
- Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data). The primary type shown is the one
ethers signs, derived from the typed data's `types`, not the type the site
states.
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
decode to as UTF-8, laid out left to right in the order of the bytes that
are signed, right-to-left characters included. Each control character,
each line or paragraph separator (U+2028, U+2029; left in the text, a
paragraph separator would end that layout for the text after it), and each
character that paints nothing (format characters such as zero-width and
bidirectional ones, default-ignorable characters such as variation
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
mark instead of acting on the text; a line feed is shown as a line break.
Bytes that are not UTF-8 are shown as "This message is not text." For
typed data, formatted domain/type/message fields (EIP-712). The primary
type shown is the one ethers signs, derived from the typed data's `types`,
not the type the site states.
- Token permission warning, at the top of the message (typed data whose
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
@@ -1897,12 +2080,20 @@ view would leave a wallet one click from deletion.
domain's `verifyingContract`; any those fields do not give is shown as
`Unknown`, and the domain, type and message lines still follow. Only typed
data that cannot be read at all is shown as raw text.
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
the site sent it. The bytes it encodes are what is signed, as an EIP-191
personal message.
- Password input and an error line
- "Sign" / "Reject" buttons
- **Transitions**:
- Typed data that states no primary type, or one other than the type it
would be signed as, or that cannot be read → shown with the error line
saying so and "Sign" disabled; only "Reject" remains
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
an even number of hex digits, the form ethers' `getBytes` reads when
signing) → shown as plain text, with the error line "This message is plain
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
the bytes from the hex, so such a message has none to sign
- "Sign" (correct password) → signs locally → closes popup (returns
signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen
@@ -1913,9 +2104,19 @@ view would leave a wallet one click from deletion.
#### StateRecovery (`state-recovery`)
- **When**: `loadState()` refused the stored profile, so the popup has no
profile at all. It is the only screen reached without one, and the only one
that never appears during ordinary use.
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
`loadState()` refusing it, so the popup has no profile at all. While the popup
is open, on any screen, it is a save refusing it: every `saveState()` reads
the stored record and runs the same check before writing, so the popup finds
it at the next navigation or ten-second refresh, whether or not the network
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
fails for any other reason, such as a storage read or write that errors, gets
the "NOT SAVED" banner instead and leaves the screen as it is. 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, the screen stays until the popup closes
or reloads: work still running in the popup, such as a transaction wait,
cannot replace it, even after the record is erased in another window. It is
the only screen that never appears during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
view, no message, no control — while every dApp call answered a generic
internal error, and no reset or wipe control existed anywhere in the product.
@@ -1942,16 +2143,20 @@ view would leave a wallet one click from deletion.
Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this
screen is up, because every screen behind it renders from the profile that
could not be read, and `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton.
could not be read. `showView()` is not used to raise it, for the same reason:
it reads and writes the state singleton. Under an open popup the screen is
passed to `showView()` only to run the replaced screen's cleanup; from then on
`showView()` shows nothing else in that popup.
- **Both controls are required.** An export with no reset leaves the user
looking at a broken profile with no way to use the wallet again; a reset with
no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
and for the same reason: there is no password to gate this with, since there
is no profile to check one against.
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
nothing on this path writes state at all.
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
can become readable again under an open popup, erased in another window, and
the next save from that popup then succeeds. A popup opened after that opens
normally.
### External Services
@@ -2019,8 +2224,8 @@ Dev dependencies (not shipped in extension):
| Package | Version | License | Purpose |
| ------------------ | ------- | ------- | ------------------------- |
| `esbuild` | 0.27.3 | MIT | JS bundler (inlines deps) |
| `tailwindcss` | 4.2.1 | MIT | CSS compilation |
| `@tailwindcss/cli` | 4.2.1 | MIT | Tailwind CLI |
| `tailwindcss` | 4.3.1 | MIT | CSS compilation |
| `@tailwindcss/cli` | 4.3.1 | MIT | Tailwind CLI |
| `jest` | 30.2.0 | MIT | Test runner |
| `prettier` | 3.8.1 | MIT | Code formatter |
@@ -2044,7 +2249,7 @@ a bare string in `manifest/firefox.json` (MV2):
```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
style-src 'self'; img-src 'self' data:;
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
base-uri 'none'
```
@@ -2056,15 +2261,17 @@ wallet's own UI. Escaping is the primary fix for that (see
`src/shared/html.js`); this is the second line, so an escape that does slip
cannot reach the network.
Four directives are looser than `'self'`, each for a reason that does not
`style-src 'self'` admits the stylesheet and nothing inline: both browsers
refuse a `style="..."` attribute and a `<style>` block. So the popup's markup,
in `src/popup/index.html` and in the HTML the view helpers build, carries
Tailwind classes and never a `style` attribute. Script that sets `element.style`
is not affected; that is how the views show and hide their error lines. An
inline style that slips in anyway is refused with a console error, which fails
both end-to-end suites.
These directives differ from a plain `'self'`, each for a reason that does not
generalise:
- `style-src 'unsafe-inline'` — `src/popup/index.html` and the view helpers set
presentation through `style="..."` attributes, which CSP blocks without this.
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
Firefox has never implemented `style-src-attr`, so there is no narrower
spelling that works on both targets. It permits inline **style**; script stays
under `script-src`, which does not allow `'unsafe-inline'`.
- `img-src data:` — identicons are generated in the popup by
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
@@ -2123,6 +2330,17 @@ the log level and turns the banner on, and that is all it may ever do: it feeds
constant directly, so no runtime toggle in a release build can reach the
hardcoded test phrase.
At the raised log level the console also shows the wallet's addresses with their
balances and ENS names, the token contracts looked up, and a line for each
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
for its response. A request is logged by its HTTP method, the origin of its URL
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
refresh, the token lookup and a failed endpoint check in settings name the
endpoint by its origin too. The URL's path and query string, where RPC providers
put API keys, any user name and password in it, and the request body are never
logged.
### Key Decisions
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
@@ -2249,7 +2467,8 @@ indexes it as a real token transfer.
act on and what the user believes they own rather than what the history
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
so they cannot answer the question differently. A symbol the list maps to no
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
contract at all — the native asset's labels: `"ETH"` and every network's
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
it.
@@ -2258,7 +2477,8 @@ indexes it as a real token transfer.
fewer than 1,000 holders are hidden from transaction history by default.
Legitimate tokens have substantial holder counts; poisoning tokens typically
have zero. This catches new poisoning contracts that use novel symbols not in
the known token list.
the known token list. A transfer whose token's holder count is unknown (see
Data Model) is kept: only a reported count below 1,000 hides it.
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
contract addresses. Token transfers involving these contracts are filtered
@@ -2268,7 +2488,9 @@ indexes it as a real token transfer.
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are
excluded from the token selector on the send screen. This prevents users from
accidentally interacting with a spoofed token that appeared in their balance
via a fake Transfer event.
via a fake Transfer event. A token whose holder count is unknown is kept in
the selector. The selector offers only tokens in the balance list, so such a
token is one on the bundled list or one the user tracks.
- **Dust transaction filtering**: A second wave of the same attack used real
native ETH transfers instead of fake tokens. Transaction
@@ -2292,8 +2514,9 @@ indexes it as a real token transfer.
both cases identically to the history. The fraud contract blocklist is applied
unconditionally on that selector and is not consulted by the balance list at
all. The low-holder setting also gates the send selector, while the balance
list's own 1,000-holder floor is unconditional (see Data Model). The dust
threshold applies to the transaction history alone.
list's own 1,000-holder floor is unconditional (see Data Model). An unknown
holder count passes the history and send-selector filters but not that floor.
The dust threshold applies to the transaction history alone.
#### Phishing Domain Protection
+357 -86
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-07-06
last_modified: 2026-10-04
---
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
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`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
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
@@ -89,87 +100,198 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
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
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else:
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
# Lint stage — fast feedback on formatting and lint issues
# Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
RUN golangci-lint run --config .golangci.yml ./...
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# 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:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the ordering edge. BuildKit runs stages in parallel by default,
and a stage nothing depends on is not built at all, so without these two
lines a red gate would not fail the 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
(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:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```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
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script bootstraps, runs the gate phases, and then builds the image, so a
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
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
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
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
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
- **The test command should use the conditional verbose rerun pattern.** Run
tests without `-v` (verbose) first. If tests fail, automatically rerun with
`-v` to show full output. This keeps CI logs and `docker build` output clean
on success (just package/suite summaries) while providing full diagnostic
detail on failure (every test case, every assertion). The 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
test:
@@ -209,11 +338,26 @@ style conventions are in separate documents:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
@go test -count=1 -timeout 90s -race -cover ./... || \
{ 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:
```makefile
@@ -239,10 +383,84 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
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
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
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent: fetch it from
`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
with the version and date (YYYY-MM-DD).
@@ -374,12 +639,14 @@ style conventions are in separate documents:
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
- `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
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `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`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- 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.
+479
View File
@@ -45,6 +45,485 @@ but the review is broader than any of them.
# Completed Steps
- 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: 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
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). 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. A
further request of the same kind from a site whose prompt is still unanswered
is now refused with EIP-1193 `-32002`, the code a second transaction already
gets, and opens no window. Signing by `personal_sign`, `eth_sign` and
`eth_signTypedData_v4` counts as one kind. Other sites are not affected, and
the site may ask again once the user has answered. A connection prompt whose
toolbar popup closed before it connected, and which nothing shows any more, is
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
([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and
`deniedSites` stored the hostname alone, so a grant to `https://dapp.example`
also authorised `http://dapp.example` and every port on that host, and the
prompts named only the hostname. Both lists now store and match the origin
(`scheme://host[:port]`), the key the connections approved without "Remember"
already used, in `src/background/index.js` and in Settings, whose site lists
and `AUTISTMASK_REMOVE_SITE` carry the origin too. The connection, transaction
and signature prompts show the origin. Entries saved by hostname before this
change are not migrated (pre-1.0): they match no site, and Settings lists them
until they are removed.
- 2026-10-04: A page's request is credited only to the site the browser says
sent it ([#407](https://git.eeqj.de/sneak/AutistMask/issues/407)). Where the
browser does not give the sender's origin (Firefox before 126), the background
used the tab's page, so a frame from another site would have been treated as
the site embedding it, and with no tab it used an origin the page wrote into
the message. It now uses the URL of the frame that sent the message, and
refuses the request with code 4100 when the browser gives neither. The content
script no longer writes an origin into the message.
- 2026-10-04: `make test` takes 8-13s on the shared build host, down from
17-25s, measured in alternating runs before and after the change
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
the tests (`tests/support/popupBoot.js`) resets jest's module registry so that
everything under `src/` loads fresh, and that also reloaded `ethers`,
`libsodium-wrappers-sumo`, `qrcode` and `ethereum-blockies-base64` every time.
Those four libraries are now loaded once per test file and handed to every
boot. No test or assertion changed.
- 2026-10-04: A token whose scale is unknown reads the same on the Send screen
as on the confirmation screen
([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses'
explorer reports disagree on a token's `decimals`, the Send screen showed the
stored figure while the confirmation screen it leads to said
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
its cause: for an unknown scale it says the wallet does not know how many
decimal places the token uses and that the transaction cannot be sent, instead
of asking the user to go back and try again, which cannot help. For any other
cause it is unchanged.
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
decoded on the approval screen
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
`src/shared/uniswap.js` had no arm for it, so the screen named the step and
showed no token or amount. The input side is the path's first token with
`amountInMax`, the output side the last token with `amountOut`. When the
transaction has such a step, the `Amount` figure reads `Up to <amount>`,
whichever step set it, there and on the wait, success and error screens,
except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any
amount at or above the `uint160` maximum) or `All available (V4 open delta)`.
In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise
`Token Out` and `Min. received` keep the output side's own token and figure.
V3 exact-out (`0x01`) is still not decoded.
- 2026-10-04: `make test` runs jest in three worker processes
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
one worker per CPU core: about 47 processes and 7-8 GiB per run on the shared
48-core build host. They now pass `--maxWorkers=3`, and the suite takes 23-29s
there: inside the 30-second cap in `script/test`, which is unchanged, but not
by much, because `tests/persistedFieldContract.test.js` alone takes most of it
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). One or two workers
went past the cap. `make check`, the pre-commit hook and `script/cibuild` all
run the suite through these scripts.
- 2026-10-04: The error container on each dApp approval screen keeps its height
when an error appears
([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
+96 -16
View File
@@ -15,6 +15,15 @@ const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox");
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
// 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
@@ -31,7 +40,7 @@ const AUDITED_MODULE = "src/shared/constants.js";
// 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
// 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
// it is one of the audited bundles. script/verify-build is handed this and
@@ -441,6 +450,10 @@ function getBuildInfo() {
async function build() {
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();
if (!receiptPath) {
console.warn(
@@ -597,27 +610,94 @@ async function build() {
console.log("Build complete: dist/chrome/ and dist/firefox/");
}
// Run only as a program. Required as a module — which is how
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
// builds nothing and writes nothing.
if (require.main === module) {
build().catch((err) => {
console.error(
`Build failed: ${err && err.message ? err.message : err}`,
);
process.exit(1);
});
// make dev: build, then build again after every change under `dirs`, until
// interrupted. A failed build is reported and watching carries on, so a
// half-finished edit does not end it. A change that arrives while a build is
// running is not lost: it causes one more build as soon as that one finishes.
// A directory created after this starts is not watched until a restart.
//
// make dev sets no AUTISTMASK_BUILD_RECEIPT, so these builds write no receipt
// and nothing can verify what they leave in dist/.
//
// `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
// three functions 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".
// Run only as a program. Required as a module — which is how
// tests/buildForbiddenInputs.test.js and tests/buildWatch.test.js reach the
// functions below — this file builds nothing and writes nothing.
if (require.main === module) {
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 = {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
watch,
};
+15 -5
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
that survives the spam filters).
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.
The confirmation screen shows:
@@ -285,11 +287,13 @@ not appear and may be permanently lost.
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
pages. When a site requests access to your wallet:
1. A popup appears showing the site's hostname and the address that will be
shared.
1. A popup appears showing the site's origin (its scheme, host and port, for
example `https://app.example`) and the address that will be shared.
2. Click "Allow" to connect or "Deny" to reject.
3. Optionally check "Remember my choice for this site" to skip the prompt next
time.
time. The choice applies to that exact origin only: a choice remembered for
`https://app.example` does not cover `http://app.example` or another port of
the same host, which ask again.
When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data, network fee, network and
@@ -331,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
balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero.
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
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
1. **Confirm `main` is green in CI.** The `check` workflow
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`,
and the `Dockerfile` runs `make check` as a build step, so a green `check`
run is a green `make check`. Find the run for the exact release commit on the
(`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
`script/check` and then builds the image uncached, so a green `check` run is
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
`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"],
"host_permissions": ["<all_urls>"],
"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": {
"16": "icons/icon16.png",
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"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": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
+4 -4
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
@@ -15,14 +15,14 @@
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1",
"@tailwindcss/cli": "4.3.1",
"esbuild": "^0.27.3",
"eslint": "10.8.1",
"globals": "17.11.0",
"jest": "^30.2.0",
"playwright-core": "1.56.0",
"prettier": "^3.8.1",
"tailwindcss": "^4.2.1"
"tailwindcss": "4.3.1"
},
"dependencies": {
"ethereum-blockies-base64": "^1.0.2",
+4 -4
View File
@@ -1,14 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, test-verify-build, lint, fmt-check).
# Our own extension to scripts-to-rule-them-all. Must not modify any files.
# script/check: run all checks (test, lint, fmt-check). Our own
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/test-verify-build"
"$SCRIPT_DIR/check-censored"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
# script/check-censored: assert that the competitor name RULES.md bars appears
# nowhere in this repo, and nowhere in the built extension, except where it is
# deliberate. Our own extension to scripts-to-rule-them-all, run from
# script/check and from make build.
# deliberate. Our own extension to scripts-to-rule-them-all, run by the
# Dockerfile's lint phase and by make build.
#
# Where the name is allowed, and why each one is not negotiable away:
#
+19 -4
View File
@@ -1,13 +1,28 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# 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
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
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 "$@"
+11 -1
View File
@@ -1,6 +1,8 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
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 "$@"
+20 -1
View File
@@ -4,10 +4,29 @@ set -eu
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() {
cd "$ROOT"
echo "Formatting..."
yarn run fmt 2>&1
run_yarn run fmt
}
main "$@"
+20 -1
View File
@@ -5,10 +5,29 @@ set -eu
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() {
cd "$ROOT"
echo "Checking formatting..."
yarn run fmt-check 2>&1
run_yarn run fmt-check
}
main "$@"
@@ -14,7 +14,7 @@
// 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
// 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`,
// before a full bundle. It reads sources from disk and matches import
+13 -41
View File
@@ -1,51 +1,23 @@
#!/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 pinned one is the one in the image; a host's own install must not be
# able to decide whether this repo is green. From a host this therefore builds
# the Dockerfile's `lint` stage, which runs this same script inside the image.
#
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
# what stops the recursion, so `make check` inside the CI build lints in place
# instead of trying to reach a docker daemon it does not have.
# The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
case "${AUTISTMASK_LINT_NATIVE:-}" in
1)
echo "Linting..."
yarn run lint 2>&1
return 0
;;
"") ;;
*)
# Set but not recognized: say so rather than silently taking the
# docker path, which would look like the variable had no effect.
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
exit 1
;;
esac
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2
exit 1
fi
echo "Linting in the pinned container..."
# --progress=plain: the default progress renderer collapses the lint
# output on success, and a lint run whose output cannot be seen is not
# evidence that it ran.
#
# --output=type=cacheonly: the exit status is the whole result; exporting
# an image afterwards costs about ten times the lint itself.
docker build --progress=plain --target lint \
--output=type=cacheonly . 2>&1
docker build --no-cache \
--target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" .
}
main "$@"
+10 -40
View File
@@ -1,49 +1,19 @@
#!/bin/sh
# script/test: run the test suite.
#
# The timeout bounds a hung suite; it is not a performance budget. On a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
# the bound. Inside the image the same suite also pays a cold jest cache and
# shares the runner with the rest of the build, which is not what that budget
# describes, so the Dockerfile raises the bound through
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
# produces a red that means nothing, and teaches "just run it again".
# 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:
# --target because a phase that is not the last stage is built only when
# 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.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Running tests (timeout ${TIMEOUT}s)..."
status=0
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
[ "$status" -eq 0 ] && return 0
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
# assertion, and the verbose rerun would only spend the same wall clock
# to be killed again.
if [ "$status" -eq 124 ]; then
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
exit 1
fi
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
# is not a duration it accepts. The suite never ran, so it neither timed out
# nor failed, and the verbose rerun would only reprint the same complaint.
if [ "$status" -eq 125 ]; then
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
exit 1
fi
echo "--- Rerunning with --verbose for details ---"
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
docker build --no-cache \
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
}
main "$@"
+5 -2
View File
@@ -4,7 +4,7 @@
# scripts-to-rule-them-all.
#
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
# caps make test at 20 seconds and a browser suite does not fit. Run it
# 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
# used-but-not-imported identifier in make check, but only this suite sees
# what a view actually does when it runs.
@@ -45,7 +45,10 @@ main() {
trap 'cleanup; exit 130' INT TERM
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..."
# 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.
#
# 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
# on every push, in a job separate from check.
#
@@ -44,7 +44,9 @@ main() {
trap 'cleanup; exit 130' INT TERM
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 .
echo "Running the Firefox e2e suite..."
+2 -1
View File
@@ -2,7 +2,8 @@
# script/test-verify-build: exercise every failure mode of
# script/verify-build, and what make build does with dist/ after one of them
# (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
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
+171 -76
View File
@@ -57,9 +57,13 @@ const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true }
//
// A site is its full origin (scheme://host[:port]), here and in the
// remembered allowedSites/deniedSites lists alike: a grant to
// https://dapp.example says nothing about http://dapp.example or another port.
const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } }
// Pending approval requests: { id: { origin, resolve } }
const pendingApprovals = {};
// One transaction approval at a time, wallet-wide.
@@ -83,7 +87,8 @@ const pendingApprovals = {};
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
// Sign approvals do not take this slot: a signature consumes no nonce. They are
// limited per site instead; see findPendingApproval().
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
@@ -95,7 +100,7 @@ let txApprovalSlot = null;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;
const APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
@@ -132,6 +137,22 @@ function releaseTxApprovalSlotFor(approvalId) {
}
}
// One site-connection approval and one sign approval per site at a time: a
// page that asks again before the user has answered is refused with the code
// above instead of opening another window, so it cannot bury the user in
// prompts. The pending approval itself holds the place, so a caller must test
// this and raise its approval with nothing awaited in between.
function findPendingApproval(origin, type) {
return Object.values(pendingApprovals).find(
(approval) => approval.origin === origin && approval.type === type,
);
}
const APPROVAL_PENDING_MESSAGE =
"AutistMask is already waiting for your answer to a request of this kind" +
" from this site, so this one was not shown. Please answer that one," +
" then send this one again.";
// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
@@ -284,7 +305,12 @@ async function proxyRpc(method, params) {
return json.result;
}
// The site-connection approval the toolbar popup is set to open, or null while
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
let toolbarPopupApprovalId = null;
function resetPopupUrl() {
toolbarPopupApprovalId = null;
if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" });
}
@@ -387,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,
// 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
@@ -411,7 +437,13 @@ async function openApprovalWindow(id) {
width: popupWidth,
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(
currentWin.left + (currentWin.width - popupWidth) / 2,
);
@@ -420,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;
try {
win = await windowsCreate(opts);
} catch (e) {
// The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both land
// on the !win branch below, which settles the approval.
// callback namespace reported it by handing back no window; both
// leave win null.
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];
if (!approval) {
@@ -459,29 +508,36 @@ async function openApprovalWindow(id) {
// Open an approval popup and return a promise that resolves with the user decision.
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) {
function requestApproval(origin) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, hostname, resolve };
pendingApprovals[id] = { id, origin, resolve, type: "site" };
if (actionNs && typeof actionNs.openPopup === "function") {
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionNs.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
} catch {
openApprovalWindow(id);
}
showInToolbarPopup(id);
} else {
openApprovalWindow(id);
}
});
}
// Show a site-connection approval in the toolbar popup, or in a separate popup
// window when the browser will not open the toolbar popup.
function showInToolbarPopup(id) {
toolbarPopupApprovalId = id;
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionNs.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
} catch {
openApprovalWindow(id);
}
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
@@ -495,13 +551,12 @@ function requestApproval(origin, hostname) {
// screen never named.
// `slot` is the transaction-approval slot its caller holds. Handing the
// approval's id to it is what makes retiring the approval free the slot.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
hostname,
approvedTx,
approvedFrom,
resolve,
@@ -517,13 +572,12 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
function requestSignApproval(origin, signParams, approvedFrom) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
hostname,
signParams,
approvedFrom,
resolve,
@@ -601,11 +655,11 @@ runtime.onConnect.addListener((port) => {
// in the worker — a balance refresh in flight, another site's approval — has
// gone on running the whole time. Loading here used to replace the very
// objects that work was holding.
async function rememberSiteChoice(field, address, hostname) {
async function rememberSiteChoice(field, address, origin) {
await updateState((s) => {
if (!s[field][address]) s[field][address] = [];
if (!s[field][address].includes(hostname)) {
s[field][address].push(hostname);
if (!s[field][address].includes(origin)) {
s[field][address].push(origin);
}
});
}
@@ -618,12 +672,11 @@ async function handleConnectionRequest(origin) {
return { error: { message: "No accounts available" } };
}
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const denied = s.deniedSites[activeAddress] || [];
// Check denied list
if (denied.includes(hostname)) {
if (denied.includes(origin)) {
return {
error: {
code: 4001,
@@ -634,25 +687,50 @@ async function handleConnectionRequest(origin) {
// Check allowed list or in-memory connected
if (
allowed.includes(hostname) ||
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
) {
return { result: [activeAddress] };
}
const pending = findPendingApproval(origin, "site");
if (pending) {
// A toolbar popup that closed before it connected leaves its prompt
// pending, and once the toolbar popup is set to open something else
// nothing shows that prompt: the site would be refused until the
// address changed. Show it again. A prompt in a window or in a
// connected popup is settled when that closes, and one the toolbar
// popup is still set to open is a click away, so those are left alone.
if (
actionNs &&
typeof actionNs.openPopup === "function" &&
!pending.windowId &&
!pending.portConnected &&
toolbarPopupApprovalId !== pending.id
) {
showInToolbarPopup(pending.id);
}
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
// Open approval popup
const decision = await requestApproval(origin, hostname);
const decision = await requestApproval(origin);
if (decision.approved) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, hostname);
await rememberSiteChoice("allowedSites", activeAddress, origin);
} else {
connectedSites[origin + ":" + activeAddress] = true;
}
return { result: [activeAddress] };
} else {
if (decision.remember) {
await rememberSiteChoice("deniedSites", activeAddress, hostname);
await rememberSiteChoice("deniedSites", activeAddress, origin);
}
return {
error: {
@@ -663,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 = [
"eth_blockNumber",
"eth_call",
"eth_chainId",
"eth_estimateGas",
"eth_gasPrice",
"eth_getBalance",
@@ -681,7 +760,6 @@ const PROXY_METHODS = [
"eth_getTransactionReceipt",
"eth_maxPriorityFeePerGas",
"eth_sendRawTransaction",
"net_version",
"web3_clientVersion",
"eth_feeHistory",
"eth_getBlockTransactionCountByHash",
@@ -698,10 +776,9 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { result: [] };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
allowed.includes(hostname) ||
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
) {
return { result: [activeAddress] };
@@ -731,10 +808,9 @@ async function handleRpc(method, params, origin) {
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -806,10 +882,9 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_getPermissions") {
const s = await getState();
const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
allowed.includes(hostname) ||
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
return { result: [] };
@@ -835,10 +910,9 @@ async function handleRpc(method, params, origin) {
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -868,9 +942,16 @@ async function handleRpc(method, params, origin) {
"Only proceed if you fully understand what you are signing.";
}
if (findPendingApproval(origin, "sign")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestSignApproval(
origin,
hostname,
signParams,
activeAddress,
);
@@ -884,10 +965,9 @@ async function handleRpc(method, params, origin) {
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -903,9 +983,16 @@ async function handleRpc(method, params, origin) {
},
};
}
if (findPendingApproval(origin, "sign")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestSignApproval(
origin,
hostname,
signParams,
activeAddress,
);
@@ -946,10 +1033,9 @@ async function handleSendTransaction(params, origin) {
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -976,7 +1062,7 @@ async function handleSendTransaction(params, origin) {
if (!slot) {
return {
error: {
code: TX_APPROVAL_PENDING_CODE,
code: APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};
@@ -1023,7 +1109,6 @@ async function handleSendTransaction(params, origin) {
const decision = await requestTxApproval(
origin,
hostname,
approvedTx,
activeAddress,
slot,
@@ -1097,10 +1182,9 @@ async function broadcastAccountsChanged() {
}
for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin);
const hasPermission =
activeAddress &&
(allowed.includes(hostname) ||
(allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]);
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
@@ -1113,7 +1197,7 @@ async function broadcastAccountsChanged() {
}
// Tell every open tab of a site Settings removed that it has no account.
async function broadcastSiteRemoved(hostname) {
async function broadcastSiteRemoved(origin) {
let tabs;
try {
tabs = await tabsQuery({});
@@ -1121,7 +1205,7 @@ async function broadcastSiteRemoved(hostname) {
return;
}
for (const tab of tabs) {
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
if (!tab.url || new URL(tab.url).origin !== origin) continue;
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
@@ -1288,18 +1372,29 @@ if (windowsNs && windowsNs.onRemoved) {
// Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_RPC") {
// Derive origin from trusted sender info to prevent origin spoofing.
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
if (sender.origin) {
trustedOrigin = sender.origin;
} else if (sender.tab && sender.tab.url) {
// The origin is the one the browser reports for the sender, never one
// the message carries. Firefox before 126 gives no sender.origin, so
// the origin of sender.url is used: the frame that sent the message,
// not the tab's page, which may be another site embedding that
// frame. With neither, the request is refused.
let trustedOrigin = sender.origin;
if (!trustedOrigin && sender.url) {
try {
trustedOrigin = new URL(sender.tab.url).origin;
trustedOrigin = new URL(sender.url).origin;
} catch {
// keep fallback
// an unparseable URL leaves the origin unknown
}
}
if (!trustedOrigin) {
sendResponse({
error: {
code: 4100,
message:
"The wallet could not tell which site sent this request.",
},
});
return false;
}
handleRpc(msg.method, msg.params, trustedOrigin)
.then((response) => {
sendResponse(response);
@@ -1337,10 +1432,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id];
if (approval) {
const resp = {
hostname: approval.hostname,
origin: approval.origin,
};
const resp = { origin: approval.origin };
if (approval.type === "tx") {
resp.type = "tx";
// The populated transaction, and the address it was raised
@@ -1355,7 +1447,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.approvedFrom = approval.approvedFrom;
}
// Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
resp.isPhishingDomain = isPhishingDomain(
extractHostname(approval.origin),
);
sendResponse(resp);
} else {
sendResponse(null);
@@ -1689,23 +1783,24 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
extractHostname(key.slice(0, key.lastIndexOf(":"))),
key.slice(0, key.lastIndexOf(":")),
),
);
return false;
}
// Settings removed this site and has already dropped its remembered
// entries. Its connections approved without "Remember" end here, under
// every address, and its open tabs are told it has no account.
// Settings removed this site (msg.origin) and has already dropped its
// remembered entries. Its connections approved without "Remember" end
// here, under every address, and its open tabs are told it has no account.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
for (const key of Object.keys(connectedSites)) {
const origin = key.slice(0, key.lastIndexOf(":"));
if (extractHostname(origin) === msg.hostname) {
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.hostname);
broadcastSiteRemoved(msg.origin);
return false;
}
});
module.exports = { PROXY_METHODS };
-1
View File
@@ -30,7 +30,6 @@ window.addEventListener("message", (event) => {
id,
method,
params,
origin: location.origin,
})
.then((response) => {
if (response) {
+70 -117
View File
@@ -6,7 +6,10 @@
<title>AutistMask</title>
<link rel="stylesheet" href="styles.css" />
</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">
<!-- ============ GLOBAL TITLE BAR ============ -->
<div
@@ -107,8 +110,7 @@
</div>
<div
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2"
style="visibility: hidden"
class="text-xs mb-2 border border-border border-dashed p-2 invisible"
>
Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet
@@ -259,10 +261,7 @@
<!-- recent transactions across all addresses -->
<div>
<div
class="font-bold bg-section py-1 px-2"
style="margin-left: -0.5rem; margin-right: -0.5rem"
>
<div class="font-bold bg-section py-1 px-2 -mx-2">
Recent Transactions
</div>
<div id="home-tx-list">
@@ -270,7 +269,7 @@
</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">
<span
@@ -392,22 +391,14 @@
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<div class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</div>
<div id="export-privkey-address" class="text-xs mb-3"></div>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
@@ -539,8 +530,7 @@
/>
<div
id="send-to-error"
class="text-xs"
style="min-height: 1.25rem; color: #cc0000"
class="text-xs min-h-[1.25rem] text-[#cc0000]"
></div>
</div>
<div class="mb-2">
@@ -551,12 +541,20 @@
class="text-xs text-muted"
></span>
</div>
<input
type="text"
id="send-amount"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0.0"
/>
<div class="flex gap-1">
<input
type="text"
id="send-amount"
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>
<button
id="btn-send-review"
@@ -608,7 +606,7 @@
<div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div>
</div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div id="confirm-fee" class="mb-3 invisible">
<div class="text-xs text-muted mb-1">Network fee</div>
<div id="confirm-fee-amount" class="text-xs"></div>
<!-- Holds its one line of space from the first paint, so
@@ -616,22 +614,13 @@
nothing. The placeholder is never seen. -->
<div
id="confirm-fee-reserve"
class="text-xs text-muted"
style="visibility: hidden"
class="text-xs text-muted invisible"
>
reserve pending
</div>
</div>
<div
id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-warning"
class="mb-2"
style="visibility: hidden"
>
<div id="confirm-warnings" class="mb-2 invisible"></div>
<div id="confirm-recipient-warning" class="mb-2 invisible">
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
@@ -640,24 +629,13 @@
Double-check the address before sending.
</div>
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div
id="confirm-contract-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient is a smart contract. Sending ETH
or tokens directly to a contract may result in permanent
loss of funds.
</div>
</div>
<div
id="confirm-burn-warning"
class="mb-2"
style="visibility: hidden"
>
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
></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"
>
@@ -665,11 +643,7 @@
here are permanently destroyed and cannot be recovered.
</div>
</div>
<div
id="confirm-etherscan-warning"
class="mb-2"
style="visibility: hidden"
>
<div id="confirm-etherscan-warning" class="mb-2 invisible">
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
@@ -679,34 +653,27 @@
</div>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount.
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div
id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<!-- Its sentence names why the fee could not be estimated,
so show() in confirmTx.js sets it. -->
<div
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
The network fee could not be estimated, so this transaction
cannot be checked against your balance. Please go back and
try again.
</div>
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
@@ -717,8 +684,7 @@
</div>
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-confirm-send"
@@ -833,8 +799,7 @@
</button>
<div
id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3"
style="visibility: hidden"
class="text-xs border border-border border-dashed p-2 mt-3 invisible"
></div>
</div>
@@ -862,8 +827,7 @@
</div>
<div
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2">
<label class="block mb-1 text-xs text-muted"
@@ -1049,8 +1013,7 @@
type="text"
inputmode="numeric"
id="settings-dust-threshold"
class="border border-border p-1 text-xs bg-bg text-fg"
style="width: 10ch"
class="border border-border p-1 text-xs bg-bg text-fg w-[10ch]"
/>
<span class="text-xs text-muted">gwei</span>
</div>
@@ -1127,8 +1090,7 @@
<div
id="settings-debug-well"
class="bg-well p-3 mx-1 mb-3"
style="display: none"
class="bg-well p-3 mx-1 mb-3 hidden"
>
<h3 class="font-bold mb-1">Debug</h3>
<label
@@ -1156,8 +1118,7 @@
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
@@ -1230,8 +1191,7 @@
</div>
<div
id="delete-wallet-lost-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-delete-wallet-lost-confirm"
@@ -1286,8 +1246,7 @@
</p>
<div
id="delete-address-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-delete-address-confirm"
@@ -1316,8 +1275,7 @@
</div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label>
@@ -1399,8 +1357,7 @@
/>
<div
id="settings-addtoken-info"
class="text-xs text-muted mt-1 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-muted mt-1 min-h-[1.25rem] invisible"
></div>
<button
id="btn-settings-addtoken-manual"
@@ -1563,7 +1520,7 @@
with extreme caution.
</div>
<p class="mb-2">
<span id="approve-tx-hostname" class="font-bold"></span>
<span id="approve-tx-origin" class="font-bold"></span>
wants to send a transaction.
</p>
@@ -1633,8 +1590,7 @@
</div>
<div
id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
style="visibility: hidden"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
></div>
<div class="flex justify-between">
<button
@@ -1664,21 +1620,13 @@
funds. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-sign-hostname" class="font-bold"></span>
<span id="approve-sign-origin" class="font-bold"></span>
wants you to sign a message.
</p>
<div
id="approve-sign-danger-warning"
class="mb-3 p-2 text-xs font-bold"
style="
visibility: hidden;
min-height: 1.25rem;
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
class="mb-3 p-2 text-xs font-bold invisible min-h-[1.25rem] bg-[#fee2e2] text-[#991b1b] border-2 border-[#dc2626] rounded-[6px]"
></div>
<div class="mb-3">
@@ -1695,8 +1643,15 @@
<div class="text-xs text-muted mb-1">Message</div>
<div
id="approve-sign-message"
class="text-xs break-all"
style="max-height: 12rem; overflow-y: auto"
class="text-xs break-all max-h-48 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>
@@ -1710,8 +1665,7 @@
</div>
<div
id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
style="visibility: hidden"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
></div>
<div class="flex justify-between">
<button
@@ -1742,7 +1696,7 @@
</div>
<div class="mb-3">
<p class="mb-2">
<span id="approve-hostname" class="font-bold"></span>
<span id="approve-origin" class="font-bold"></span>
wants to connect to your wallet.
</p>
<div class="text-xs text-muted mb-1">
@@ -1835,8 +1789,7 @@
</div>
<div
id="state-recovery-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-state-recovery-reset"
+37 -2
View File
@@ -48,8 +48,20 @@ function renderWalletList() {
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;
// 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() {
if (refreshInFlight) return;
refreshInFlight = true;
@@ -62,6 +74,7 @@ async function doRefreshAndRender() {
state.blockscoutUrl,
state.trackedTokens,
state.networkId,
pageClosed.signal,
),
]);
state.lastBalanceRefresh = Date.now();
@@ -83,6 +96,7 @@ async function doRefreshAndRender() {
const ctx = {
renderWalletList,
doRefreshAndRender,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
addWallet.show();
@@ -155,7 +169,28 @@ async function init() {
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// 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 {
await loadState();
} catch (e) {
@@ -244,7 +279,7 @@ async function init() {
renderWalletList();
restoreView();
doRefreshAndRender();
setInterval(doRefreshAndRender, 10000);
refreshTimer = setInterval(doRefreshAndRender, 10000);
}
}
+10
View File
@@ -64,3 +64,13 @@ body {
white-space: nowrap;
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,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
@@ -68,6 +69,9 @@ function init(ctx) {
}
require("./addressDetail").show();
} 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);
log.errorf("Adding token failed for", contractAddr, detail);
// lookupTokenInfo() rejects a contract with a one-line message
+12 -2
View File
@@ -190,7 +190,12 @@ async function importMnemonic(ctx) {
// Scan for used HD addresses beyond index 0.
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) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
@@ -300,7 +305,12 @@ async function importXprvKey(ctx) {
// Scan for used HD addresses beyond index 0.
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) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
+17 -65
View File
@@ -11,8 +11,10 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -31,8 +33,8 @@ const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the
// send and the private-key export path check it before asking for a password,
// so a wallet that cannot derive its keys says so instead of failing after the
// user has typed one in.
// so a wallet whose key getSignerForAddress refuses says so instead of failing
// after the user has typed one in.
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
@@ -64,7 +66,7 @@ function show() {
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;";
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
@@ -88,62 +90,6 @@ function show() {
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 ensNameMap = new Map();
@@ -153,6 +99,7 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
currentNetwork().chainId,
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
@@ -188,6 +135,7 @@ async function loadTransactions(address) {
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
@@ -196,6 +144,9 @@ async function loadTransactions(address) {
renderTransactions(txs);
} 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);
$("tx-list").innerHTML =
'<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.
const nameStr = escapeHtml(title || ensName || "");
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 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 += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
@@ -312,9 +263,10 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
// There is no private key to export for an address this wallet
// cannot derive. Without this the export screen would take a
// password and then report it as wrong.
// This address's private key can be derived from the stored key,
// but export goes through getSignerForAddress, which refuses a key
// 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();
if (defect) {
showFlash(defect.shortMessage);
+15 -63
View File
@@ -10,14 +10,17 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
balanceLine,
unknownableAmount,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
@@ -36,62 +39,6 @@ const { walletDefect } = require("../../shared/walletDefects");
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 ensNameMap = new Map();
let currentSymbol = null;
@@ -106,7 +53,7 @@ function show() {
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = "ETH";
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
@@ -156,7 +103,7 @@ function show() {
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
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)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
@@ -201,9 +148,9 @@ function show() {
if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
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)
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)
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;
@@ -226,6 +173,7 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
currentNetwork().chainId,
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
@@ -271,6 +219,7 @@ async function loadTransactions(address, tokenId) {
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
@@ -279,6 +228,9 @@ async function loadTransactions(address, tokenId) {
renderTransactions(txs);
} 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);
$("address-token-tx-list").innerHTML =
'<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.
const nameStr = escapeHtml(title || ensName || "");
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 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 += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
+84 -32
View File
@@ -10,9 +10,13 @@ const {
attachCopyHandlers,
onViewLeave,
formatFee,
tokenLabel,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const {
formatEther,
formatUnits,
@@ -26,6 +30,7 @@ const {
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const {
resolveTokenDecimals,
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.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
@@ -218,8 +212,12 @@ function showTxFee(approvedTx) {
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both.
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
// fee reads the same on both. In the native currency of the network shown
// above, as the value is.
$("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId),
);
let detail =
gasLimit.toString() +
@@ -263,6 +261,7 @@ function showTxApproval(details) {
amount: formatTxValue(ethValue),
token: "ETH",
tokenSymbol: null,
chainId: approvedTx.chainId,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
@@ -306,7 +305,7 @@ function showTxApproval(details) {
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known
@@ -328,8 +327,15 @@ function showTxApproval(details) {
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
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 =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
ethValueFormatted +
" " +
nativeCurrencyByChainId(approvedTx.chainId) +
(usdStr ? " (" + usdStr + ")" : "");
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) {
try {
const bytes = Uint8Array.from(
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
return toUtf8String(getBytes(hex));
} catch {
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
// `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
@@ -645,7 +679,7 @@ function showSignApproval(details) {
pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-origin").textContent = details.origin;
$("approve-sign-from").innerHTML = approvalAddressHtml(
details.approvedFrom,
);
@@ -657,15 +691,33 @@ function showSignApproval(details) {
? "Typed data (EIP-712)"
: "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) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else {
refusal = typedDataRefusal(sp);
} else if (isHexMessage(sp.message)) {
const decoded = decodeHexMessage(sp.message);
if (decoded !== null) {
$("approve-sign-message").textContent = decoded;
$("approve-sign-message").innerHTML =
markInvisibleCharacters(decoded);
} 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)
@@ -687,7 +739,6 @@ function showSignApproval(details) {
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) {
showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true;
@@ -732,7 +783,7 @@ async function show(id) {
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-origin").textContent = details.origin;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
@@ -771,9 +822,10 @@ function setSignButtonBusy(busy) {
}
// Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose keys
// cannot be derived. Without this the screen would take a password and fail
// after deriving it. Reject stays available; the wallet is not touched.
// the address the approval was raised for belongs to a wallet whose key
// getSignerForAddress refuses. Without this the screen would take a password
// and fail after deriving it. Reject stays available; the wallet is not
// touched.
// Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId, address) {
const owner = findWalletFor(address);
+126 -37
View File
@@ -11,14 +11,17 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
goBack,
onViewLeave,
formatFee,
} = require("./helpers");
const { state } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances");
@@ -42,10 +45,10 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
maxEthAmount,
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus");
let pendingTx = null;
@@ -53,6 +56,10 @@ let pendingTx = null;
// filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING;
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() {
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) {
const blockie = blockieHtml(address);
return (
@@ -81,16 +83,30 @@ function valueWithUsd(text, usdAmount) {
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) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// 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);
// Transaction type
@@ -98,7 +114,7 @@ function show(txInfo) {
$("confirm-type").textContent =
"ERC-20 token transfer (" + symbol + ")";
} else {
$("confirm-type").textContent = "Native ETH transfer";
$("confirm-type").textContent = "Native " + symbol + " transfer";
}
// Token contract section (ERC-20 only)
@@ -131,18 +147,11 @@ function show(txInfo) {
);
$("confirm-to-ens").classList.add("hidden");
// Amount (with inline USD)
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,
);
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -162,7 +171,7 @@ function show(txInfo) {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH",
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
@@ -197,6 +206,32 @@ function show(txInfo) {
// estimate landing later never moves anything.
$("confirm-amount-fee-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.
// Without the token's scale estimateGas() cannot encode the transfer, so
// the estimate fails every time and going back cannot help; any other
// failure may clear on a retry.
$("confirm-fee-unknown-error").textContent =
isErc20 && txInfo.tokenDecimals == null
? "The network fee could not be estimated, because this wallet" +
" does not know how many decimal places this token uses, so" +
" this transaction cannot be sent."
: "The network fee could not be estimated, so this transaction" +
" cannot be checked against your balance. Please go back and" +
" try again.";
renderValidation(txInfo);
@@ -232,7 +267,9 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
const { canSend, codes } = validateTransfer({
isErc20,
@@ -244,7 +281,8 @@ function renderValidation(txInfo) {
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html.
// sentences live in the reserved elements in index.html, except the
// gas and fee-unknown ones, which show() sets.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
@@ -273,9 +311,13 @@ function renderValidation(txInfo) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" ETH.",
" " +
symbol +
".",
);
}
@@ -346,8 +388,8 @@ async function estimateGas(txInfo) {
}
// What the node will require to be reserved, which is what the gate
// must be: the send pins no fee fields, so it is broadcast as a
// type-2 transaction priced at maxFeePerGas.
// must be: the send is broadcast as a type-2 transaction priced at
// maxFeePerGas, which only a max ETH send pins (see below).
const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) {
throw new Error("no usable gas price from the provider");
@@ -364,24 +406,54 @@ async function estimateGas(txInfo) {
// The fee line goes through formatFee(), as the approval screen's
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
$("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved";
" " +
nativeCurrency() +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
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);
} catch (e) {
log.errorf("gas estimation failed:", e.message);
log.errorf("gas estimation failed:", e.shortMessage || e.message);
if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false);
@@ -392,18 +464,19 @@ async function estimateGas(txInfo) {
}
// 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
// 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
// the fees that were actually filled in, then sign and broadcast it. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
// 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.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx) {
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount) };
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
} else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the
@@ -465,6 +538,15 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("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;
if (!password) {
showError(
@@ -474,7 +556,6 @@ function init(_ctx) {
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
hideError("confirm-tx-password-error");
@@ -494,6 +575,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("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;
try {
const signer = getSignerForAddress(
@@ -504,7 +589,11 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
tx = await populateVerifyAndSend(
connectedSigner,
pendingTx,
maxSendFees,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
+26 -13
View File
@@ -12,6 +12,7 @@ const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
let deleteWalletIndex = null;
let lostPasswordIndex = null;
@@ -20,21 +21,31 @@ let ctx = null;
// 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
// 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) {
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
// 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
// see the second space and cannot type a string that matches the stored
// name. Comparing collapsed on 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.
// name. Characters that paint nothing, such as a zero-width space, are
// invisible the same way and are removed first. Comparing this form on
// 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) {
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
@@ -174,14 +185,16 @@ function init(_ctx) {
return;
}
// Case, surrounding spaces and repeated inner spaces are not part
// of the confirmation; see confirmKey(). This asks whether the
// user knows which wallet they are on; it is not a secret, and
// refusing "wallet 2" for "Wallet 2" would only teach the user to
// distrust the control.
const typed = $("delete-wallet-lost-name-input").value;
// Case, surrounding spaces, repeated inner spaces and invisible
// characters are not part of the confirmation; see confirmKey().
// This asks whether the user knows which wallet they are on; it is
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
// teach the user to distrust the control. An empty field is
// 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);
if (confirmKey(typed) !== confirmKey(expected)) {
if (typed === "" || typed !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " +
expected +
+11 -2
View File
@@ -98,7 +98,7 @@ function show(walletIdx, addrIdx) {
$("export-privkey-title").textContent =
wallet.name + " — Address " + (addrIdx + 1);
const addrContainer = $("export-privkey-dot").parentElement;
const addrContainer = $("export-privkey-address");
addrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(addrContainer);
@@ -152,7 +152,16 @@ async function reveal() {
}
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
// leave hook. A per-button wipe would only cover this one path.
+86 -31
View File
@@ -13,10 +13,12 @@
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -52,9 +54,8 @@ const VIEWS = [
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read. It is never reached through showView() — by then the
// state singleton this file writes on every navigation refuses to be read
// — but it is listed so that every view-hiding loop covers it.
// cannot be read, never by showView() (see there), but listed so that
// every view-hiding loop covers it.
"state-recovery",
];
@@ -85,12 +86,28 @@ function hideError(id) {
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) {
// 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;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
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) {
const el = document.getElementById(`view-${v}`);
if (el) {
@@ -252,16 +269,42 @@ function unknownableAmount(balance) {
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:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
// figure.
function formatFee(wei) {
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when
// the ETH price is known. The USD value is of the exact fee, not of the
// truncated figure.
function formatFee(wei, symbol) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
truncateAmountNeverZero(eth) +
" " +
symbol +
(usd ? " (" + usd + ")" : "")
);
}
@@ -282,7 +325,7 @@ function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd =
price && amount !== null
? formatUsd(amount * price) || "&nbsp;"
? escapeHtml(formatUsd(amount * price)) || "&nbsp;"
: "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute.
@@ -292,7 +335,7 @@ function balanceLine(symbol, amount, price, tokenId) {
: "";
return (
`<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>${qty}</span>` +
`</span>` +
@@ -303,7 +346,7 @@ function balanceLine(symbol, amount, price, tokenId) {
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
"ETH",
nativeCurrency(),
parseFloat(addr.balance || "0"),
getPrice("ETH"),
"ETH",
@@ -387,23 +430,26 @@ function truncateMiddle(str, maxLen) {
// 16 colors evenly spaced around the hue wheel (22.5° apart),
// 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 = [
"#d92626",
"#d96926",
"#d9ac26",
"#c2d926",
"#80d926",
"#3dd926",
"#26d953",
"#26d996",
"#26d9d9",
"#2696d9",
"#2653d9",
"#3d26d9",
"#8026d9",
"#c226d9",
"#d926ac",
"#d92669",
"bg-[#d92626]",
"bg-[#d96926]",
"bg-[#d9ac26]",
"bg-[#c2d926]",
"bg-[#80d926]",
"bg-[#3dd926]",
"bg-[#26d953]",
"bg-[#26d996]",
"bg-[#26d9d9]",
"bg-[#2696d9]",
"bg-[#2653d9]",
"bg-[#3d26d9]",
"bg-[#8026d9]",
"bg-[#c226d9]",
"bg-[#d926ac]",
"bg-[#d92669]",
];
function addressColor(address) {
@@ -413,7 +459,12 @@ function addressColor(address) {
function addressDotHtml(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
@@ -462,6 +513,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
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) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
@@ -520,7 +574,7 @@ function timeAgo(timestamp) {
// Shared external-link icon SVG used across all views.
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<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">` +
`<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"/>` +
@@ -660,9 +714,11 @@ module.exports = {
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
nativeCurrency,
tokenLabel,
formatFee,
addressColor,
addressDotHtml,
blockieHtml,
escapeHtml,
displaySymbol,
addressTitle,
@@ -672,7 +728,6 @@ module.exports = {
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml,
explorerUrl,
EXT_ICON,
+25 -11
View File
@@ -10,11 +10,17 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
} = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state");
const {
state,
saveState,
currentAddress,
currentNetwork,
} = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const {
updateSendBalance,
@@ -57,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH");
if (priceEl) {
priceEl.innerHTML = ethPrice
? formatUsd(ethPrice) + " USD/ETH"
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
: "&nbsp;";
}
@@ -68,12 +74,13 @@ function renderTotalValue() {
return;
}
const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " ETH";
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd;
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 titleStr = title ? escapeHtml(title) : "";
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 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 += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`;
@@ -182,7 +189,11 @@ async function loadHomeTxs(ctx) {
try {
const fetches = allAddresses.map((addr) =>
fetchRecentTransactions(addr, state.blockscoutUrl),
fetchRecentTransactions(
addr,
state.blockscoutUrl,
currentNetwork().chainId,
),
);
const results = await Promise.all(fetches);
@@ -214,6 +225,9 @@ async function loadHomeTxs(ctx) {
homeTxs = merged.slice(0, 25);
renderHomeTxList(ctx);
} 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);
const list = $("home-tx-list");
if (list) {
@@ -230,7 +244,7 @@ function walletListHtml() {
state.wallets.forEach((wallet, wi) => {
const defect = walletDefect(wallet);
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<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>`;
// No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce
@@ -244,12 +258,12 @@ function walletListHtml() {
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
const isActive = state.activeAddress === addr.address;
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
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
// single address has no remove control, because its last address
// is never removable.
const removeBtn = canRemoveAddress(wallet)
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
? `<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 titleBold = isActive ? "font-bold" : "";
@@ -270,7 +284,7 @@ function walletListHtml() {
}
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
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(
addr,
state.trackedTokens,
+174 -56
View File
@@ -5,6 +5,8 @@ const {
showFlash,
addressTitle,
displaySymbol,
escapeHtml,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -20,10 +22,25 @@ const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = 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";
// 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.
* Returns { valid: true } or { valid: false, error: "..." }.
@@ -124,7 +141,7 @@ function updateToValidation() {
function renderSendTokenSelect(addr) {
const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>';
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
const fraudSet = new Set(
(state.fraudContracts || []).map((a) => a.toLowerCase()),
);
@@ -146,6 +163,50 @@ function renderSendTokenSelect(addr) {
}
}
// The token balance and scale the Send screen states and hands the
// confirmation screen, so the two screens describe the holding the same way.
//
// The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked
// tokens, then the explorer. The stored tokenBalances[].decimals is the
// explorer's own answer alone, so reading it raw carries a null forward for a
// token the wallet does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as an unestimable
// fee. Unsendable, over a scale that was never in doubt
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
// knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that adds
// explorerDecimals()'s cross-address check, so a contract two addresses report
// different scales for answers null rather than picking one. That check has to
// apply here, because this scale encodes the transfer — it is carried forward
// so the transfer is encoded with the number the user read rather than with
// whatever the contract answers at signing time (see
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
// fetch time and cannot consult a state it is in the middle of replacing.
//
// The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount
// against a number the wallet does not vouch for, so it is withdrawn: unknown
// scale means unknown balance. It is null rather than "0": both screens state
// an unknown balance as unknown, and validateTransfer() treats it as no balance
// to spend from, which is the fail-closed side of an amount nobody can check.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
if (!tb) return { tokenBalance: "0", tokenDecimals };
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals };
}
function updateSendBalance() {
const addr = currentAddress();
if (!addr) return;
@@ -160,20 +221,19 @@ function updateSendBalance() {
$("send-balance").textContent =
"Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
" " +
nativeCurrency();
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
// A null balance is a holding whose scale nothing knows. Saying "0"
// for it would be a claim about the amount; the send itself is
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
// confirmation screen's balance line reads it; the send itself is
// refused later by transferAmountUnits() for the same missing scale.
const bal = tb ? tb.balance : "0";
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
@@ -184,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) {
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
$("btn-send-review").disabled = true;
@@ -240,59 +396,17 @@ function init(_ctx) {
let tokenSymbol = null;
let tokenBalance = null;
// The scale the amount and the balance below are rendered at, carried
// forward so the transfer is encoded with the number the user read
// rather than with whatever the contract answers at signing time. See
// src/shared/transferAmount.js.
let tokenDecimals = null;
if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
// null carried through rather than flattened to "0": the confirm
// screen states an unknown balance as unknown, and
// validateTransfer() treats it as no balance to spend from, which
// is the fail-closed side of an amount nobody can check.
tokenBalance = tb ? (tb.balance ?? null) : "0";
// Resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's
// tracked tokens, then the explorer. The stored
// tokenBalances[].decimals is the explorer's own answer alone, so
// reading it raw carries a null forward for a token the wallet
// does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as
// an unestimable fee. Unsendable, over a scale that was never in
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
// Still null when nothing knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that
// adds explorerDecimals()'s cross-address check, so a contract two
// addresses report different scales for answers null rather than
// picking one. That check has to apply here, because this value
// encodes a transfer; balances.js is formatting one explorer row
// at fetch time and cannot consult a state it is in the middle of
// replacing.
tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
// The two resolutions can therefore differ, and where they do, the
// stored `balance` is a quantity computed at a scale this screen
// has just declined to stand behind. Stating it would leave
// validateTransfer() checking the amount against a number the
// wallet does not vouch for, and — since the unknown-balance path
// is gated on the balance, not on the scale — would leave the
// fee-estimate failure as the only thing on the confirmation
// screen, which says nothing about decimals. Unknown scale means
// unknown balance. Only a stored quantity is withdrawn: the "0"
// for a token that has no row at all is an absence of holdings,
// which is true at every scale.
if (tb && tokenDecimals === null) tokenBalance = null;
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
ctx.showConfirmTx({
@@ -305,6 +419,7 @@ function init(_ctx) {
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
max: amountIsMax,
});
});
@@ -315,7 +430,10 @@ function init(_ctx) {
});
}
// Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() {
sendScreenOpenings++;
amountIsMax = false;
const errorEl = $("send-to-error");
const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = "";
+37 -29
View File
@@ -16,7 +16,12 @@ const {
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
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 showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
@@ -34,35 +39,35 @@ const { notify, sendMessage } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
// One row per hostname, however many addresses or origins it appears under,
// each with an [x] that hands it to onRemove.
function renderSiteList(containerId, hostnames, onRemove) {
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
const container = $(containerId);
const unique = [...new Set(hostnames)];
const unique = [...new Set(origins)];
if (unique.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
unique.forEach((hostname) => {
unique.forEach((origin) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
// A hostname the URL parser produced cannot carry a delimiter, so
// An origin the URL parser produced cannot carry a delimiter, so
// this is escaped for the rule rather than for a known hole — the
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `<span>${escapeHtml(origin)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
});
}
// Drop a hostname from a remembered site list under every address.
function forgetHostname(siteMap, hostname) {
// Drop a site origin from a remembered site list under every address.
function forgetOrigin(siteMap, origin) {
for (const addr of Object.keys(siteMap)) {
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
if (siteMap[addr].length === 0) {
delete siteMap[addr];
}
@@ -72,16 +77,16 @@ function forgetHostname(siteMap, hostname) {
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
async function removeAllowedSite(hostname) {
forgetHostname(state.allowedSites, hostname);
async function removeAllowedSite(origin) {
forgetOrigin(state.allowedSites, origin);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists();
}
// Removing a denied site only forgets the refusal; it connects nothing.
async function removeDeniedSite(hostname) {
forgetHostname(state.deniedSites, hostname);
async function removeDeniedSite(origin) {
forgetOrigin(state.deniedSites, origin);
await saveState();
await renderSiteLists();
}
@@ -208,12 +213,7 @@ function show() {
versionClickCount = 0;
// Show debug well if debug mode is already enabled
const debugWell = $("settings-debug-well");
if (state.debugMode) {
debugWell.style.display = "";
} else {
debugWell.style.display = "none";
}
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
$("settings-debug-mode").checked = state.debugMode;
showView("settings");
@@ -272,8 +272,14 @@ function init(ctx) {
showFlash("Wrong network: expected " + net.name + ".");
return;
}
} catch (e) {
log.errorf("RPC validation fetch failed:", e.message);
} catch {
// 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.");
return;
}
@@ -295,8 +301,10 @@ function init(ctx) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch (e) {
log.errorf("Blockscout validation failed:", e.message);
} catch {
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.");
return;
}
@@ -425,7 +433,7 @@ function init(ctx) {
if (versionClickCount >= 10) {
versionClickCount = 0;
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);
}
function tokenLabel(t) {
function nameAndSymbol(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
}
@@ -60,7 +60,7 @@ function renderDropdown() {
let html = '<option value="">-- select --</option>';
for (const t of tokens) {
const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
html +=
`<option value="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` +
@@ -135,6 +135,7 @@ function init(_ctx) {
addr,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
@@ -152,6 +153,9 @@ function init(_ctx) {
renderDropdown();
ctx.doRefreshAndRender();
} 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);
log.errorf("Adding token failed for", addr, detail);
// lookupTokenInfo() rejects a contract with a one-line message
+10 -1
View File
@@ -134,7 +134,16 @@ async function reveal() {
}
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", () => {
goBack();
+9 -2
View File
@@ -3,8 +3,10 @@
// Everything else in the popup assumes a loaded profile: showView() reads and
// 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
// the time this runs, loadState() has REFUSED, deliberately, and reading the
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
// the time this runs, the stored record has been REFUSED, deliberately: at
// 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
// 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.
*/
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 =
(problem && (problem.problem || problem.message)) || String(problem);
+16 -13
View File
@@ -13,6 +13,7 @@ const {
isoDate,
timeAgo,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
@@ -21,8 +22,8 @@ const {
goBack,
} = require("./helpers");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
@@ -41,13 +42,10 @@ function getTransactionType(tx) {
return "Token Approval";
return "Contract Call";
}
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
return "Native ETH 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">`;
// By the token contract, not the symbol: a token chooses its own symbol
// 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 txAddressHtml(address, ensName, title) {
@@ -84,6 +82,11 @@ function show(tx) {
isContractCall: tx.isContractCall || false,
method: tx.method || 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();
@@ -179,7 +182,7 @@ function render() {
if (el) el.classList.add("hidden");
}
loadFullTxDetails(tx.hash, tx.to);
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
@@ -197,7 +200,7 @@ function showDetailField(sectionId, contentId, value) {
section.classList.remove("hidden");
}
function populateOnChainDetails(txData) {
function populateOnChainDetails(txData, chainId) {
// Block number
if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number));
@@ -227,7 +230,7 @@ function populateOnChainDetails(txData) {
showDetailField(
"tx-detail-fee-section",
"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 actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -304,7 +307,7 @@ async function loadFullTxDetails(txHash, toAddress) {
const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
populateOnChainDetails(txData, chainId);
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
+17 -19
View File
@@ -13,9 +13,10 @@ const {
explorerUrl,
displaySymbol,
clearViewStack,
tokenLabel,
} = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -86,9 +87,13 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait();
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 =
txInfo.token === "ETH"
? "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
@@ -127,13 +132,16 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
try {
receipt = await provider.getTransactionReceipt(txHash);
} 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
// receipt": the RPC failed, the chain said nothing. Declaring
// the timeout off it would report a confirmed transaction as
// failed — which matters most on a resumed wait, where the
// first poll is already past the deadline.
answered = false;
log.errorf("poll receipt failed:", e.message);
log.errorf("poll receipt failed:", e.shortMessage || e.message);
}
// The lookup is async: the wait may have ended while it was in
// 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
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -223,7 +232,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
const symbol =
txInfo.token === "ETH"
? "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = {
amount: txInfo.amount,
@@ -237,17 +246,6 @@ function showSuccess(txInfo, txHash, blockNumber) {
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) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
@@ -320,7 +318,7 @@ function showError(txInfo, txHash, message) {
const symbol =
txInfo.token === "ETH"
? "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = {
amount: txInfo.amount,
+2 -2
View File
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
});
}
} 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
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
});
}
} 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.
// Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result. toDecimals() is that check, shared with the
// send path rather than copied: the bundled list stores numbers, the
// explorer's copy arrives as a string, and a token the user added by hand
// carries whatever lookupTokenInfo() got back, so the accepted types are
// enumerated rather than coerced.
// reporting that call's result. toDecimals() is that check, stopping at the 80
// places formatUnits() accepts, and shared with the send path rather than
// copied: the bundled list stores numbers, the explorer's copy arrives as a
// string, and a token the user added by hand carries whatever lookupTokenInfo()
// got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
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
// learns to carry a new transaction field must not start picking one up out of
// 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 = [
"to",
"value",
"data",
"nonce",
"gasLimit",
"gasPrice",
"maxFeePerGas",
+13 -2
View File
@@ -54,9 +54,11 @@ const {
formatEther,
getAddress,
getBytes,
toQuantity,
verifyMessage,
verifyTypedData,
} = require("ethers");
const { nativeCurrencyByChainId } = require("./networks");
// The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
@@ -407,12 +409,21 @@ function assertWithinCeilings(tx) {
price = normalizeQuantity(tx.gasPrice, "gas price");
}
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(
"This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) +
" ETH, which is more than the " +
" " +
nativeCurrency +
", which is more than the " +
formatEther(MAX_TOTAL_FEE) +
" ETH this wallet will sign for.",
" " +
nativeCurrency +
" this wallet will sign for.",
);
}
}
+52 -24
View File
@@ -10,7 +10,7 @@ const {
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log");
const { log, debugFetch, urlOrigin } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
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
// 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.
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 {
const resp = await debugFetch(
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
// not the same as a holding of zero, and must never render as one.
const bal = scale === null ? null : formatTokenBalance(raw, scale);
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
// the whole balance list, so it stays strict and admits a token
// only on a reported count — an unreported one is no evidence.
// A legitimate token still reaches the list through the known
// null means the explorer reported no readable count, which is
// not the same as a count of zero. This gate is not the
// low-holder display filter: it has no user-facing off switch and
// governs the whole balance list, so it stays strict and admits a
// token only on a reported count — an unreported one is no
// evidence, 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
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders =
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
@@ -190,20 +198,24 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
}
return balances;
} catch (e) {
log.errorf("fetchTokenBalances failed:", e.message);
if (!signal?.aborted) {
log.errorf("fetchTokenBalances failed:", e.message);
}
return null;
}
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `signal` is as for fetchTokenBalances().
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
signal,
) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const updates = [];
@@ -220,6 +232,7 @@ async function refreshBalances(
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch((e) => {
if (signal?.aborted) return;
log.errorf(
"ETH balance failed",
addr.address,
@@ -243,10 +256,11 @@ async function refreshBalances(
);
})
.catch((e) => {
if (signal?.aborted) return;
log.errorf(
"ENS reverse failed",
addr.address,
e.message,
e.shortMessage || e.message,
);
// Keep existing addr.ensName if we had one
}),
@@ -258,6 +272,7 @@ async function refreshBalances(
addr.address,
blockscoutUrl,
trackedTokens,
signal,
).then((balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
@@ -279,8 +294,9 @@ async function refreshBalances(
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
// `signal` is as for fetchTokenBalances().
async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
@@ -289,7 +305,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
symbol = await contract.symbol();
log.debugf("symbol() =", symbol);
} 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).");
}
@@ -297,7 +315,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
decimals = await contract.decimals();
log.debugf("decimals() =", decimals);
} 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).");
}
@@ -305,7 +325,12 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
name = await contract.name();
log.debugf("name() =", name);
} 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;
}
@@ -321,7 +346,8 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// 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);
const provider = getProvider(rpcUrl, networkId);
const used = [];
@@ -344,11 +370,13 @@ async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
]);
return { addr, index, isUsed: balance > 0n || txCount > 0 };
} catch (e) {
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
if (!signal?.aborted) {
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
}
return { addr, index, isUsed: false };
}
}),
-2
View File
@@ -33,7 +33,6 @@ const DEBUG_MNEMONIC = DEBUG
: null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -69,7 +68,6 @@ module.exports = {
BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH,
+14 -4
View File
@@ -32,7 +32,8 @@ function setCache(address, name) {
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);
if (cached !== undefined) return cached;
@@ -42,17 +43,26 @@ async function resolveEnsName(address, rpcUrl, networkId) {
setCache(address, name);
return name;
} 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
return null;
}
}
async function resolveEnsNames(addresses, rpcUrl, networkId) {
async function resolveEnsNames(addresses, rpcUrl, networkId, signal) {
const results = new Map();
await Promise.all(
addresses.map(async (addr) => {
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
results.set(
addr,
await resolveEnsName(addr, rpcUrl, networkId, signal),
);
}),
);
return results;
+15 -6
View File
@@ -9,13 +9,22 @@
const LOW_HOLDER_THRESHOLD = 1000;
// Parse an explorer-supplied holders_count into a number, or null when the
// explorer did not report one. Anything unparseable is unknown too: a count
// we cannot read is not a count of zero.
// Parse an explorer-supplied holders_count into a number, or null when it is
// not one. Only a whole number of zero or more, or a string made of nothing
// 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) {
if (raw === null || raw === undefined || raw === "") return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
if (typeof raw === "number") {
return Number.isSafeInteger(raw) && raw >= 0 ? raw : 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
+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) {
const method = (opts && opts.method) || "GET";
const body = opts && opts.body;
log.debugf("fetch →", method, url, body || "");
const origin = urlOrigin(url);
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);
log.debugf("fetch ←", resp.status, url);
log.debugf("fetch ←", resp.status, origin);
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;
}
// Build a block explorer link for the given path type and value.
// type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) {
return `${network.explorerUrl}/${type}/${value}`;
// The native currency of the network with this chain id. A transaction's
// value and fee are labelled with the one of the chain the transaction is on,
// which need not be the active network. `ETH` when the chain id is missing or
// no network here has it.
function nativeCurrencyByChainId(chainId) {
const network = networkByChainId(chainId);
return network ? network.nativeCurrency : "ETH";
}
module.exports = {
@@ -89,5 +92,5 @@ module.exports = {
isKnownNetworkId,
networkById,
networkByChainId,
explorerLink,
nativeCurrencyByChainId,
};
+11 -10
View File
@@ -102,11 +102,11 @@ function tokenRefs(value) {
// A list of strings, for the fields whose entries are dereferenced as text:
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` in the background).
// src/shared/transactions.js) and each address's origin list in the site maps
// below (`o !== origin` filters, `list.includes(origin)` in the background).
//
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// with a malformed entry DROPPED rather than repaired. A number in an origin
// list names no site and a number in fraudContracts names no contract, so there
// is nothing to repair either to, and the empty list is a legitimate value that
// survives. The result is a fresh array of primitives, so it shares no
@@ -116,21 +116,22 @@ function textList(value) {
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
// full scheme://host[:port] of a site.
//
// The container check these had (truthy and not an array) is not the floor:
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
// level below it. saveState() merges these maps per key and then per hostname
// level below it. saveState() merges these maps per key and then per origin
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
// rendered, which is why every save from then on failed while the UI looked
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
// revoke button (`list.filter()`), and the background's
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
// `allowed.includes(origin)` gate, dereference it the same way; on that last
// one a stored string would also answer a SUBSTRING match, so a corrupt map
// could widen a site permission rather than merely throw.
//
// An address key whose value is not a list of hostnames is dropped entirely: it
// An address key whose value is not a list of origins is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. A stored own
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
// can never be a wallet address, so it grants nothing either, and keeping it
@@ -143,9 +144,9 @@ function siteMap(value) {
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames);
const origins = textList(value[address]);
if (origins.length === 0) continue;
defineOwn(out, address, origins);
}
return out;
}
-23
View File
@@ -104,27 +104,6 @@ function getAddressValue(addr) {
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.
//
// A partial total is shown and named as partial: the figure is the ETH and
@@ -149,6 +128,4 @@ module.exports = {
formatUsd,
formatAddressTotal,
getAddressValue,
getWalletValue,
getTotalValue,
};
+22 -15
View File
@@ -122,9 +122,9 @@ function currentNetwork() {
return networkById(state.networkId);
}
// The persisted fields as they stood at the end of this page's last
// loadState() or saveState(). saveState() diffs the live state against this
// to find only the fields THIS page actually changed.
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began. saveState() diffs
// 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
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -304,7 +304,7 @@ function mergeAddress(base, ours, theirs) {
}
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// same way mergeListByIdentity() merges an array — by key, not by whole-
// object diff — so a key one page added or removed applies independently of
// a key another page edited. Unlike an array's identity function, an object
@@ -353,25 +353,25 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
return result;
}
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// allowedSites/deniedSites: { [address]: [origin, ...] }. The origin
// list is itself membership, not a leaf — the background appends a newly
// approved/denied hostname to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
// approved/denied origin to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters an origin out of it in place, from a
// different page. Merge it the same way wallets are merged: identity is the
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
// origin itself, so a merged pair is always equal and mergeItem is a no-op
// pick.
function mergeHostnameList(base, ours, theirs) {
function mergeOriginList(base, ours, theirs) {
return mergeListByIdentity(
base,
ours,
theirs,
(hostname) => hostname,
(origin) => origin,
(b, o, t) => t,
);
}
function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
return mergeMapByKey(base, ours, theirs, mergeOriginList);
}
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
@@ -422,8 +422,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// address) apply independently instead of colliding as the same field.
//
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
// by address key and then by hostname within each address's list), for the
// identical reason: the background appends a newly approved/denied hostname
// by address key and then by origin within each address's list), for the
// identical reason: the background appends a newly approved/denied origin
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
// filters one out in place, from a different page. A whole-field diff here
// doesn't just lose data, it is a security defect — a stale page's save can
@@ -464,7 +464,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
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");
// 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,
@@ -521,7 +524,11 @@ async function saveStateOnce() {
// exactly as it stood; see the note above.
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
+7 -2
View File
@@ -20,6 +20,10 @@
// (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
// 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;
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL;
if (s.length <= MAX_SYMBOL_LENGTH) return s;
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
const chars = Array.from(s);
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
}
module.exports = {
+9 -3
View File
@@ -11,8 +11,8 @@
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// that may bear it, or to null. Null means the symbol belongs to the native
// asset, which has no contract at all, so no contract may bear it and every
// one that does is a spoof. "ETH" is the only such entry today; the rule is
// written so that a second one needs no change here or at any call site.
// one that does is a spoof. "ETH" is one such entry, and every network's
// `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
// bundled list belong to two real contracts each, and answering with one of
@@ -34,6 +34,11 @@ function normalizeAddress(addr) {
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:
//
// NFKC collapses compatibility variants that render as the ASCII
@@ -82,7 +87,7 @@ function normalizeAddress(addr) {
function normalizeSymbol(symbol) {
return String(symbol || "")
.normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
.replace(INVISIBLE_CHARACTERS, "")
.trim()
.toUpperCase();
}
@@ -104,5 +109,6 @@ function isSpoofedSymbol(symbol, contractAddress) {
}
module.exports = {
INVISIBLE_CHARACTERS,
isSpoofedSymbol,
};
+7 -1
View File
@@ -6,6 +6,7 @@
// 511 tokens.
const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
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
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol.
// 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
// 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.
const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) {
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
}
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
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 { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
// 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
// same thing here as it does on the screens that refuse to format one.
@@ -28,7 +29,7 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function parseTx(tx, addrLower) {
function parseTx(tx, addrLower, chainId) {
const from = tx.from?.hash || "";
const to = tx.to?.hash || "";
const rawWei = tx.value || "0";
@@ -36,7 +37,7 @@ function parseTx(tx, addrLower) {
const method = tx.method || null;
// 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 exactValue = formatEther(rawWei);
let rawAmount = rawWei;
@@ -90,10 +91,11 @@ function parseTx(tx, addrLower) {
holders: null,
isContractCall: toIsContract,
method: method,
chainId: chainId,
};
}
function parseTokenTransfer(tt, addrLower) {
function parseTokenTransfer(tt, addrLower, chainId) {
const from = tt.from?.hash || "";
const to = tt.to?.hash || "";
// The explorer's own answer, or null. Never a default: a transfer of
@@ -135,10 +137,12 @@ function parseTokenTransfer(tt, addrLower) {
contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "",
),
// null when the explorer reported no count: unknown, not zero. The
// low-holder filter declines to judge a null, so a legitimate token
// is not hidden because a field went missing upstream.
// null when the explorer reported no readable count: unknown, not
// zero. The low-holder filter declines to judge a null, so a
// legitimate token is not hidden because a field went missing
// upstream.
holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId,
};
}
@@ -221,7 +225,15 @@ function mergeTransactions(txs, tokenTransfers) {
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);
const addrLower = normalizeAddress(address);
@@ -254,8 +266,10 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
(ttJson.items || []).map((tt) =>
parseTokenTransfer(tt, addrLower, chainId),
),
);
const result = txs.slice(0, count);
+6 -4
View File
@@ -27,9 +27,11 @@
const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not
// an answer this wallet can use.
const MAX_DECIMALS = 255;
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
// 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 =
"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.
// 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
// 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
// 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
// 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
// common scale is exact.
const { parseUnits } = require("ethers");
const { parseUnits, formatEther } = require("ethers");
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.
//
// isErc20 — token transfer rather than a native ETH transfer
@@ -139,13 +161,12 @@ function validateTransfer({
const feeFp = known ? feeWei : null;
if (isErc20) {
// A token can declare more than 18 decimals, and its balance is
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
// are read: an amount with more was refused above, so the places
// after them cannot decide whether the amount fits.
// Only the first 18 places of the balance are read: an amount with
// more was refused above, so the places after them cannot decide
// whether the amount fits.
const tokenText =
typeof tokenBalance === "string"
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
? maxTokenAmount(tokenBalance)
: tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -174,6 +195,8 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
validateTransfer,
};
+88 -25
View File
@@ -84,22 +84,43 @@ function present(value) {
//
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// a zero minimum is a literal zero slippage floor and is stated as one. Nor
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// 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");
// The two 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 a third phrasing of "not named": these
// say different things.
// The Universal Router's V2 exact-in spells "the pair already holds the input
// tokens" as an amount of zero: universal-router
// `contracts/libraries/Constants.sol` declares
// `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 ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// decode() is not told the network, so it takes either.
const WETH_ADDRESSES = [
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -178,6 +199,7 @@ function decodeBalanceCheck(input) {
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
function decodeV2SwapExactIn(input) {
try {
const d = coder.decode(
@@ -185,7 +207,7 @@ function decodeV2SwapExactIn(input) {
input,
);
return {
amountIn: d[1],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
@@ -198,11 +220,6 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
@@ -447,6 +464,7 @@ function decode(data, toAddress, sources) {
let outputToken = null;
let minOutput = null;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
@@ -499,7 +517,13 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x0e) {
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) {
@@ -521,6 +545,16 @@ function decode(data, toAddress, sources) {
}
}
if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
}
}
if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w) {
@@ -559,12 +593,19 @@ function decode(data, toAddress, sources) {
// Resolve token info. A null token on either side means the calldata
// named no currency for it; tokenInfo() refuses rather than calling it
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
// decode, and it is answered here rather than left to that rule.
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side.
// Any other output keeps its own token and figure: a swap that buys
// USDC and then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset = !present(outputToken) && !present(minOutput);
const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -603,16 +644,33 @@ function decode(data, toAddress, sources) {
}
if (present(inputAmount)) {
// Two amounts need no scale to describe and are named rather than
// formatted: V4's open delta, which is not a quantity at all (see
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
// first — the sentinel is not a bigint and cannot be compared with
// one.
// Three amounts need no scale to describe and are named rather
// than formatted: V4's open delta and V2's already-paid zero,
// neither of which is a quantity at all (see OPEN_DELTA and
// ALREADY_PAID), and an unbounded permit. Those two tests come
// first — the sentinels are not bigints and cannot be compared
// with one.
let amount;
if (inputAmount === OPEN_DELTA) {
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) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) {
// A V2 exact-out swap spends at most this figure, whichever
// step set the line (its amountInMax, the WRAP_ETH of a swap
// paid in ETH, a permit), so it is said to be a maximum, in
// `raw` too: the wait, success and error screens show `raw` as
// the transaction's amount.
const most = amountText(inputAmount, inInfo);
amount = {
raw: "Up to " + most.raw,
display: "Up to " + most.display,
};
} else {
amount = amountText(inputAmount, inInfo);
}
@@ -666,10 +724,15 @@ function decode(data, toAddress, sources) {
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);
details.push({
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 {
+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
// the key that was imported: the current import path derives the absolute
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
// four levels as a relative path beneath whatever depth it was given. A master
// import therefore stores a depth-4 xpub and a depth-d import stores depth
// d + 4, which makes the stored xpub an exact read on the imported key's
// depth — and it is readable without the password, unlike the key itself.
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
// derived the same four levels as a relative path beneath whatever depth it
// was given. A master import therefore stores a depth-4 xpub and a depth-d
// import stores depth d + 4, which makes the stored xpub an exact read on the
// imported key's depth — and it is readable without the password, unlike the
// key itself.
//
// 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 DEFECTS = {
+121
View File
@@ -0,0 +1,121 @@
// 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: () => {},
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,
clearPrices,
getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal,
} = require("../src/shared/prices");
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,
});
});
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", () => {
@@ -207,6 +194,14 @@ describe("the wallet list on Home", () => {
clearPrices();
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", () => {
+50
View File
@@ -31,11 +31,15 @@ const iface = new Interface(ERC20_ABI);
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// In the bundled list, at 0 decimals.
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue.
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;
function transferData(amount) {
@@ -113,6 +117,21 @@ describe("resolveTokenDecimals", () => {
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", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({
@@ -184,6 +203,22 @@ describe("decodeCalldata amount", () => {
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", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"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", () => {
const decoded = decodeCalldata(
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);
});
+140
View File
@@ -0,0 +1,140 @@
// The connection, transaction and signature prompts name the site by its full
// origin, scheme and port included, not by its bare hostname
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
// or on another port, of a host the user trusts over https must not raise a
// prompt that reads as that trusted site.
//
// Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
// The site asking, in cleartext and on a port, which is what the hostname
// alone, dapp.example, used to hide.
const ORIGIN = "http://dapp.example:8080";
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function makeElement(id) {
const classes = new Set();
const el = {
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: () => {},
};
// 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);
}
// Open the prompt the background describes with `details`, the way the popup
// does: it asks for the approval and show() draws it.
async function openApproval(details) {
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({
origin: ORIGIN,
isPhishingDomain: false,
approvedFrom: FROM,
...details,
});
},
};
approval.init({});
await approval.show(1);
}
beforeEach(() => {
state.wallets = [];
state.activeAddress = FROM;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("the connection prompt shows the origin", async () => {
await openApproval({});
expect(node("approve-origin").textContent).toBe(ORIGIN);
});
test("the transaction prompt shows the origin", async () => {
await openApproval({
type: "tx",
approvedTx: {
type: 2,
from: FROM,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "0x0",
data: "0x",
accessList: [],
},
});
expect(node("approve-tx-origin").textContent).toBe(ORIGIN);
});
test("the signature prompt shows the origin", async () => {
await openApproval({
type: "sign",
// "Hello" as the hex a dApp passes to personal_sign.
signParams: {
method: "personal_sign",
message: "0x48656c6c6f",
from: FROM,
},
});
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
});
+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(
providerWith(),
signer.address,
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
maxPriorityFeePerGas: "0x3b9aca00",
},
);
expect(approved.nonce).toBe("0x2");
expect(approved.nonce).toBe("0x7");
expect(approved.gasLimit).toBe("0x30d40");
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
+18
View File
@@ -599,6 +599,24 @@ describe("verifySignedTx field comparison", () => {
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 () => {
+468 -22
View File
@@ -39,7 +39,6 @@ const other = new Wallet(OTHER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example";
// A page the wallet has never been connected to, whose requests are refused.
const UNCONNECTED_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
@@ -75,6 +74,22 @@ const NONCE = 7;
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
// An EIP-712 document as a dApp passes it to eth_signTypedData_v4. The
// background only carries it to the approval screen, so a small one does.
const TYPED_DATA = JSON.stringify({
domain: { name: "AutistMask Test", version: "1", chainId: 1 },
primaryType: "Note",
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
],
Note: [{ name: "contents", type: "string" }],
},
message: { contents: "Hello AutistMask" },
});
// The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for.
@@ -199,7 +214,7 @@ function loadBackground(options) {
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
allowedSites: { [signer.address]: [ORIGIN] },
deniedSites: {},
};
@@ -230,6 +245,7 @@ function loadBackground(options) {
// raised through action.openPopup() opens no window at all, so this is
// the only place its id appears.
const actionPopups = [];
const openPopup = jest.fn(() => Promise.resolve());
global.chrome = {
storage,
@@ -251,9 +267,22 @@ function loadBackground(options) {
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
getLastFocused: (cb) => cb(opts.lastFocused || null),
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
// then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length });
@@ -284,7 +313,7 @@ function loadBackground(options) {
// popup: no window is created, so windows.onRemoved can never
// fire for it and the port disconnect is the only close signal
// that exists.
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
...(opts.actionPopup ? { openPopup } : {}),
},
};
@@ -331,7 +360,7 @@ function loadBackground(options) {
// The same for a message-signing approval, which pins the signing address
// at approval time in exactly the same way.
function requestSign(from) {
function requestSign(from, origin) {
let rpcResult = null;
messageListener(
{
@@ -339,7 +368,7 @@ function loadBackground(options) {
method: "personal_sign",
params: [MESSAGE, from || signer.address],
},
{ origin: ORIGIN },
{ origin: origin || ORIGIN },
(r) => {
rpcResult = r;
},
@@ -353,6 +382,24 @@ function loadBackground(options) {
};
}
// The same through eth_signTypedData_v4, whose params name the address
// first and the typed data second.
function requestTypedData() {
let rpcResult = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_signTypedData_v4",
params: [signer.address, TYPED_DATA],
},
{ origin: ORIGIN },
(r) => {
rpcResult = r;
},
);
return { result: () => rpcResult };
}
// A dApp asking to connect. The origin defaults to one the persisted
// state has never allowed, so the request really does raise a prompt
// instead of being answered from allowedSites.
@@ -427,12 +474,15 @@ function loadBackground(options) {
send,
requestTx,
requestSign,
requestTypedData,
requestSite,
connectApproval,
closeWindow,
broadcastTransaction,
created,
removed,
actionPopups,
openPopup,
storage,
// The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending
@@ -822,6 +872,238 @@ describe("one transaction approval at a time", () => {
});
});
// A page that asks again before the user has answered its last connection or
// signature request is refused, instead of opening one more window per call.
describe("one connection and one signature approval per site at a time", () => {
const PENDING_REFUSAL = {
error: {
code: -32002,
message: expect.stringMatching(/already waiting for your answer/),
},
};
test("a loop of eth_requestAccounts opens one approval and refuses the rest", async () => {
const bg = loadBackground();
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
await settle();
expect(bg.created).toHaveLength(1);
expect(requests[0].result()).toBeNull();
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
// Once the user has answered, the site may ask again.
bg.closeWindow(1);
await settle();
expect(requests[0].result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const again = bg.requestSite();
await settle();
expect(again.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
test("a loop of personal_sign opens one approval and refuses the rest", async () => {
const bg = loadBackground();
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestSign());
await settle();
expect(bg.created).toHaveLength(1);
expect(requests[0].result()).toBeNull();
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
});
test("a loop of eth_signTypedData_v4 opens one approval and refuses the rest", async () => {
const bg = loadBackground();
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestTypedData());
await settle();
expect(bg.created).toHaveLength(1);
expect(requests[0].result()).toBeNull();
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
});
test("a pending personal_sign also refuses eth_signTypedData_v4", async () => {
const bg = loadBackground();
bg.requestSign();
const typed = bg.requestTypedData();
await settle();
expect(typed.result()).toEqual(PENDING_REFUSAL);
expect(bg.created).toHaveLength(1);
});
test("in the toolbar popup, a loop of eth_requestAccounts opens it once", async () => {
const bg = loadBackground({ actionPopup: true });
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
await settle();
expect(bg.openPopup).toHaveBeenCalledTimes(1);
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
});
// A toolbar popup that closes before it connects tells the background
// nothing, so its prompt stays pending. Once the toolbar popup has been set
// to open something else, nothing shows that prompt, and the site asking
// again must show it again rather than be refused for good.
test("a toolbar prompt nothing shows any more is shown again when the site asks again", async () => {
const bg = loadBackground({ actionPopup: true });
const first = bg.requestSite();
await settle();
const id = first.id();
// Its popup closed without connecting. Another site's prompt takes the
// toolbar popup and is answered, which sets it back to the wallet.
const other = bg.requestSite(UNCONNECTED_ORIGIN);
await settle();
const otherPort = bg.connectApproval(other.id());
otherPort.decide(false, false);
otherPort.disconnect();
await settle();
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(3);
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html?approval=" + id,
);
// The user answers it, and the first request gets that answer.
bg.connectApproval(id).decide(true, false);
await settle();
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 () => {
const bg = loadBackground();
bg.requestSite();
const repeat = bg.requestSite();
const other = bg.requestSite(UNCONNECTED_ORIGIN);
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(other.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
test("another site's signature request is not held up", async () => {
const bg = loadBackground();
// Connect a second site, so that it may ask for a signature at all.
const connecting = bg.requestSite();
await settle();
const port = bg.connectApproval(connecting.id());
port.decide(true, false);
port.disconnect();
await settle();
expect(connecting.result()).toEqual({ result: [signer.address] });
bg.requestSign();
const repeat = bg.requestSign();
const other = bg.requestSign(signer.address, FRESH_ORIGIN);
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(other.result()).toBeNull();
expect(bg.created).toHaveLength(3);
});
});
// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
@@ -1966,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
// 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.
@@ -2193,12 +2496,54 @@ describe("removing an address ends a site's connection to it", () => {
});
});
// A remembered permission belongs to the origin it was granted to, scheme and
// port included (https://git.eeqj.de/sneak/AutistMask/issues/402). The stored
// state allows ORIGIN, https://dapp.example. A cleartext page on the same host,
// which a network attacker can serve, and another port on it are other sites.
describe("a remembered permission is held by the full origin", () => {
for (const origin of ["http://dapp.example", "https://dapp.example:8443"]) {
test(`an https grant does not authorise ${origin}`, async () => {
const bg = loadBackground();
expect(await siteAccounts(bg, ORIGIN)).toEqual({
result: [signer.address],
});
expect(await siteAccounts(bg, origin)).toEqual({ result: [] });
const send = bg.requestTx(TX_PARAMS, origin);
await settle();
expect(send.result()).toEqual({
error: { code: 4100, message: "Unauthorized" },
});
expect(send.id()).toBeNull();
});
}
test("Remember stores the origin, so the cleartext page on that host is asked again", async () => {
const bg = loadBackground({ actionPopup: true });
const granted = bg.requestSite(FRESH_ORIGIN);
await settle();
const grantedId = granted.id();
bg.connectApproval(grantedId).decide(true, true);
await settle();
expect(granted.result()).toEqual({ result: [signer.address] });
expect(
bg.storage.read("autistmask").allowedSites[signer.address],
).toEqual([ORIGIN, FRESH_ORIGIN]);
const cleartext = bg.requestSite("http://fresh.example");
await settle();
// Unanswered: it is waiting on a prompt of its own.
expect(cleartext.result()).toBeNull();
expect(cleartext.id()).not.toBe(grantedId);
});
});
// Settings lists the sites allowed without "Remember", which only the
// background holds, and removing a site there, from either list, disconnects
// it. These drive the real Settings view against the real background and
// click the [x] the user clicks.
describe("removing a site in Settings disconnects it", () => {
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
// The host of FRESH_ORIGIN on another port, which makes it another site.
const FRESH_OTHER_PORT = "https://fresh.example:8443";
// A site list's container. Its [x] buttons, data attributes and all, are
@@ -2226,9 +2571,9 @@ describe("removing a site in Settings disconnects it", () => {
return list;
}
// The hostnames a site list shows.
// The origins a site list shows.
function listed(list) {
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/g)].map(
(match) => match[1],
);
}
@@ -2259,10 +2604,10 @@ describe("removing a site in Settings disconnects it", () => {
allowed: element("settings-allowed-sites"),
connected: element("settings-connected-sites"),
// Click the [x] beside a site, and let what it sends run.
remove: async (list, hostname) => {
expect(listed(list)).toContain(hostname);
remove: async (list, origin) => {
expect(listed(list)).toContain(origin);
const button = list.buttons.find(
(b) => b.dataset.hostname === hostname,
(b) => b.dataset.origin === origin,
);
await button.click();
await settle();
@@ -2274,14 +2619,15 @@ describe("removing a site in Settings disconnects it", () => {
delete global.document;
});
test("Settings lists a site connected without Remember", async () => {
test("Settings lists each site by its origin", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
expect(listed(settings.connected)).toEqual(["fresh.example"]);
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
expect(listed(settings.connected)).toEqual([FRESH_ORIGIN]);
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
});
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
@@ -2293,6 +2639,7 @@ describe("removing a site in Settings disconnects it", () => {
cb([
{ id: 1, url: FRESH_ORIGIN + "/app" },
{ id: 2, url: ORIGIN + "/app" },
{ id: 3, url: FRESH_OTHER_PORT + "/app" },
]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push({ tabId, msg });
@@ -2301,7 +2648,7 @@ describe("removing a site in Settings disconnects it", () => {
};
const settings = await openSettings(bg);
await settings.remove(settings.connected, "fresh.example");
await settings.remove(settings.connected, FRESH_ORIGIN);
expect(await siteAccounts(bg)).toEqual({ result: [] });
expect(sentToTabs).toEqual([
@@ -2321,20 +2668,45 @@ describe("removing a site in Settings disconnects it", () => {
});
});
// The same hostname can hold both kinds of connection: one origin allowed
// without Remember, then another, on a different port, allowed with it.
// One origin can hold both kinds of connection under two addresses:
// remembered for one, allowed without Remember for the other.
test("removing a remembered site also ends its connection made without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
const stored = bg.storage.read("autistmask");
stored.wallets[0].addresses.push({
address: other.address,
balance: "0",
tokenBalances: [],
});
bg.storage.write("autistmask", stored);
await connect(bg, FRESH_ORIGIN, true);
bg.setActiveAddress(other.address);
const pending = bg.requestSite(FRESH_ORIGIN);
await settle();
bg.connectApproval(pending.id()).decide(true, false);
await settle();
expect(pending.result()).toEqual({ result: [other.address] });
const settings = await openSettings(bg);
await settings.remove(settings.allowed, FRESH_ORIGIN);
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
});
test("removing a remembered site leaves the same host on another port connected", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
await settings.remove(settings.allowed, "fresh.example");
await settings.remove(settings.allowed, FRESH_OTHER_PORT);
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
result: [],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
result: [signer.address],
});
});
test("a page can neither remove a site nor list the connected ones", async () => {
@@ -2343,7 +2715,7 @@ describe("removing a site in Settings disconnects it", () => {
const page = { url: FRESH_ORIGIN + "/index.html" };
const remove = bg.send(
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
{ type: "AUTISTMASK_REMOVE_SITE", origin: FRESH_ORIGIN },
page,
);
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
@@ -2357,3 +2729,77 @@ describe("removing a site in Settings disconnects it", () => {
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." },
});
});
});
+1 -2
View File
@@ -33,7 +33,6 @@ const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
@@ -81,7 +80,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
deniedSites: {},
trackedTokens: [],
lastBalanceRefresh: 0,
+8 -1
View File
@@ -5,7 +5,7 @@
//
// 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
// 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";
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
expect(html).toContain("<span>1.5000</span>");
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);
});
+1 -2
View File
@@ -19,7 +19,6 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
@@ -86,7 +85,7 @@ function loadBackground() {
tokenHolderCache: {},
fraudContracts: [],
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
deniedSites: {},
};
const storage = makeStorageStub({ autistmask: persisted });
+1 -2
View File
@@ -17,7 +17,6 @@ const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const UNKNOWN_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
@@ -41,7 +40,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: networkById(networkId).defaultRpcUrl,
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
deniedSites: {},
trackedTokens: [],
};
+2 -3
View File
@@ -21,7 +21,6 @@ const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
@@ -50,7 +49,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
deniedSites: {},
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
theme: "dark",
@@ -168,7 +167,7 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
+1 -2
View File
@@ -29,7 +29,6 @@ const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const SEPOLIA = networkById("sepolia");
@@ -67,7 +66,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
deniedSites: {},
trackedTokens: [],
};
+3 -3
View File
@@ -150,7 +150,7 @@ async function openTxApproval(to, data) {
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "tx",
hostname: "dapp.example",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
@@ -301,7 +301,7 @@ describe.each([
test("a contract creation's row says so, with no colour dot and no address line", async () => {
const html = await rowsFor(historyTx(""));
expect(html).toContain(SENTENCE);
expect(html).not.toContain("background:");
expect(html).not.toContain("bg-[#");
expect(html).not.toContain("am-address");
expect(html).not.toContain("undefined");
});
@@ -309,7 +309,7 @@ describe.each([
test("a transaction with a recipient shows its colour dot and address", async () => {
const html = await rowsFor(historyTx(RECIPIENT));
expectAddressLine(html);
expect(html).toContain("background:#");
expect(html).toContain("bg-[#");
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",
);
});
+80 -5
View File
@@ -46,6 +46,9 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
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
function makeElement(id) {
@@ -140,8 +143,14 @@ function load() {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress = A0;
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
state.allowedSites = {
[A0]: ["https://a.example"],
[B0]: ["https://b.example"],
};
state.deniedSites = {
[B0]: ["https://c.example"],
[C0]: ["https://d.example"],
};
state.viewStack = ["main", "settings"];
state.currentView = "settings";
@@ -336,6 +345,72 @@ describe("the typed confirmation", () => {
"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", () => {
@@ -388,8 +463,8 @@ describe("deleting without the password", () => {
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
expect(saved.allowedSites).toEqual({ [A0]: ["https://a.example"] });
expect(saved.deniedSites).toEqual({ [C0]: ["https://d.example"] });
});
// The route shares finishDelete() with the password route, so the
@@ -437,7 +512,7 @@ describe("deleting without the password", () => {
test("deleting the last wallet lands on Welcome with nothing left", async () => {
const { deleteWallet, state, storage } = load();
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
state.allowedSites = { [A0]: ["a.example"] };
state.allowedSites = { [A0]: ["https://a.example"] };
state.deniedSites = {};
await openLostPassword(deleteWallet, 0);
+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
// 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
// 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", () => {
const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
+146 -11
View File
@@ -8,7 +8,7 @@
// node tests/e2e/firefox/run.js [dist/firefox]
//
// 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.
//
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
@@ -46,6 +46,7 @@
const fs = require("fs");
const path = require("path");
const { isDeepStrictEqual } = require("util");
const {
Transaction,
@@ -62,6 +63,10 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");
const {
STATE_SCHEMA_VERSION,
stateProblem,
} = require("../../../src/shared/stateSchema");
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
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);
});
// 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) => {
const d = env.driver;
await d.click("#btn-welcome-add");
@@ -438,11 +574,10 @@ step(
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-site");
const hostname = await d.text("#approve-hostname");
const origin = await d.text("#approve-origin");
assert(
hostname === "127.0.0.1",
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
origin === env.server.origin,
"the site prompt names the wrong origin: " + JSON.stringify(origin),
);
const shown = await d.text("#approve-address");
assert(
@@ -494,16 +629,16 @@ step(
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-sign-hostname").textContent,
origin: document.getElementById("approve-sign-origin").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
};`,
);
assert(
screen.hostname === "127.0.0.1",
screen.origin === env.server.origin,
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
JSON.stringify(screen.origin),
);
assert(
screen.type === "Personal message",
@@ -571,7 +706,7 @@ step(
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-tx-hostname").textContent,
origin: document.getElementById("approve-tx-origin").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
@@ -582,9 +717,9 @@ step(
};`,
);
assert(
screen.hostname === "127.0.0.1",
screen.origin === env.server.origin,
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
JSON.stringify(screen.origin),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
+1 -1
View File
@@ -4,7 +4,7 @@
//
// This runs inside the pinned Playwright container; see script/test-e2e.
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
// make test at 20 seconds and a browser suite does not fit.
// make test at 60 seconds and a browser suite does not fit.
"use strict";
+62 -26
View File
@@ -22,7 +22,7 @@
"use strict";
const { Transaction } = require("ethers");
const { AbiCoder, Transaction } = require("ethers");
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
// 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_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
// Every eth_call still answers with a zero word except decimals() on the
// stub token, which the wallet reads back at signing time to compare with
// the scale the confirmation screen rendered (issue #305).
// Every eth_call still answers with a zero word except decimals(), symbol()
// and name() on the stub token. The wallet reads decimals() back at signing
// 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()
// answers something other than the value this same fixture reports through
// 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
// 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) {
const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
if (to !== STUB_TOKEN.address) return ZERO_WORD;
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;
}
@@ -405,27 +418,23 @@ function sleep(ms) {
const HOLD_POLL_MS = 25;
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
// on the same options object the route was registered with — the same
// pattern as seedTokenTransfer. This is the only way to observe the
// confirmation screen while its estimate is genuinely in flight; sampling
// the screen and hoping to win a race against the network would assert
// nothing on a slow machine.
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
// here rather than captured, so a test flips it on the same options object the
// route was registered with — the same pattern as seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine.
//
// 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
// 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();
while (opts.holdGasEstimate) {
while (opts[name]) {
if (Date.now() - started > HOLD_MAX_MS) {
report(
"held gas estimate was never released after " +
HOLD_MAX_MS +
"ms",
);
report(name + " was never released after " + HOLD_MAX_MS + "ms");
return;
}
await sleep(HOLD_POLL_MS);
@@ -447,6 +456,16 @@ function rpcReply(req, opts, report) {
return Object.assign(envelope, { result: ethCallResult(req, opts) });
}
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;
return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
@@ -539,7 +558,10 @@ async function handleRpc(route, postData, opts, report) {
return route.abort();
}
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));
@@ -595,16 +617,25 @@ function traceEnabled(raw) {
* node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* 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
* transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time.
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
* decimals() answers for the stub token, in place of the value Blockscout
* 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
* the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* 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) =>
* Promise<string|null>}>}
*/
@@ -667,7 +698,12 @@ async function installNetworkStubs(ctx, opts) {
// Blockscout v2
if (p.includes("/api/v2/")) {
await awaitRelease(opts, "holdBlockscout", report);
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);
return jsonResponse(route, {
items:
+1255 -429
View File
File diff suppressed because it is too large Load Diff
+68 -2
View File
@@ -58,7 +58,20 @@ function makeElement(id, withParent) {
remove: () => {},
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;
}
@@ -70,7 +83,8 @@ function makeDocument() {
// 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, true));
return els.get(id);
const el = els.get(id);
return el.removed ? null : el;
},
createElement: () => makeElement("created", false),
addEventListener: () => {},
@@ -300,6 +314,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", () => {
// 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.
+1
View File
@@ -91,6 +91,7 @@ describe.each([
require("../src/popup/views/" + view).init({
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
element(field).value = ADDRESS;
await element(button).listeners.click();
+33
View File
@@ -57,6 +57,39 @@ describe("parseHoldersCount", () => {
expect(parseHoldersCount("many")).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", () => {
+11
View File
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
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", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
+7 -8
View File
@@ -21,15 +21,14 @@
// escaping in src/shared/html.js is the primary fix; default-src is what
// 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:
//
// 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.
// connect-src https: http: the RPC endpoint is user-configurable, and a
// local node over http://127.0.0.1 is a
@@ -58,7 +57,7 @@ const EXPECTED_DIRECTIVES = {
"default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval'"],
"object-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"style-src": ["'self'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'", "http:", "https:"],
"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);
});
},
);

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