Compare commits

..
12 Commits
Author SHA1 Message Date
clawbot a0360a7874 chore: drop an AI vendor's tool directory from .prettierignore (closes #363)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
.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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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
31 changed files with 2011 additions and 646 deletions
-1
View File
@@ -2,4 +2,3 @@ node_modules/
yarn.lock
dist/
release/
.claude/
+49 -33
View File
@@ -342,6 +342,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 +376,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 +392,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 +475,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
@@ -619,12 +633,9 @@ 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. A report of the Chrome suite
**failing under load** is still open:
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
failing with `the extension opened no approval window within 30000ms`. So a red
`e2e-chrome` has to be read before it is believed, and those failures are the
blocker to ever making this a required check. Do not answer them with a retry
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
@@ -635,7 +646,9 @@ 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.
Firefox and geckodriver downloads the same way. The `e2e-chrome` figures predate
the two cases that wait for a receipt to end in error, which add about two
minutes of real waiting.
### Element id guard (part of `make check`)
@@ -841,10 +854,12 @@ 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
@@ -1467,18 +1482,17 @@ view would leave a wallet one click from deletion.
- 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 the
most the selected holding can send: a token's whole 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
- "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**
@@ -1959,7 +1973,9 @@ view would leave a wallet one click from deletion.
`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.
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
+116
View File
@@ -45,6 +45,122 @@ but the review is broader than any of them.
# Completed Steps
- 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
+3 -2
View File
@@ -240,8 +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, or click "Max" to fill in everything you can send: a
token's whole balance, or your ETH balance minus the network fee.
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:
+22 -3
View File
@@ -437,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,
);
@@ -455,10 +461,23 @@ async function openApprovalWindow(id) {
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) {
+4 -1
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
+9 -62
View File
@@ -11,6 +11,8 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -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();
@@ -313,9 +259,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);
+5 -59
View File
@@ -17,6 +17,8 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
@@ -37,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;
@@ -157,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);
@@ -202,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;
+5 -14
View File
@@ -10,6 +10,7 @@ const {
attachCopyHandlers,
onViewLeave,
formatFee,
tokenLabel,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const {
@@ -73,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) {
@@ -832,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);
+11 -7
View File
@@ -13,6 +13,7 @@ const {
displaySymbol,
nativeCurrency,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
goBack,
onViewLeave,
@@ -20,6 +21,7 @@ const {
} = require("./helpers");
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");
@@ -47,7 +49,6 @@ const {
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus");
let pendingTx = null;
@@ -67,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 (
@@ -542,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(
@@ -551,7 +556,6 @@ function init(_ctx) {
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
hideError("confirm-tx-password-error");
+24 -3
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");
@@ -278,6 +280,17 @@ 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() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when
@@ -312,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.
@@ -446,6 +459,11 @@ function addressDotHtml(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>`;
}
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">`;
}
// Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) {
@@ -492,6 +510,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");
@@ -691,9 +712,10 @@ module.exports = {
addressHoldsFunds,
unknownableAmount,
nativeCurrency,
tokenLabel,
formatFee,
addressColor,
addressDotHtml,
blockieHtml,
escapeHtml,
displaySymbol,
addressTitle,
@@ -703,7 +725,6 @@ module.exports = {
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml,
explorerUrl,
EXT_ICON,
+4 -3
View File
@@ -63,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH");
if (priceEl) {
priceEl.innerHTML = ethPrice
? formatUsd(ethPrice) + " USD/ETH"
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
: "&nbsp;";
}
@@ -79,7 +79,8 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd;
if (subEl) {
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
subEl.innerHTML =
escapeHtml(formatAddressTotal(getAddressValue(addr))) || "&nbsp;";
}
}
@@ -280,7 +281,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,
+2 -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)}"` +
+1 -6
View File
@@ -13,6 +13,7 @@ const {
isoDate,
timeAgo,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
@@ -23,7 +24,6 @@ const {
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");
@@ -48,11 +48,6 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
}
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
return (
+1 -12
View File
@@ -13,8 +13,8 @@ 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");
@@ -243,17 +243,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">`;
-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,
-7
View File
@@ -85,12 +85,6 @@ function nativeCurrencyByChainId(chainId) {
return network ? network.nativeCurrency : "ETH";
}
// Build a block explorer link for the given path type and value.
// type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) {
return `${network.explorerUrl}/${type}/${value}`;
}
module.exports = {
NETWORKS,
SUPPORTED_CHAIN_IDS,
@@ -99,5 +93,4 @@ module.exports = {
networkById,
networkByChainId,
nativeCurrencyByChainId,
explorerLink,
};
-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,
};
+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 = {
+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 = {
+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", () => {
+89 -2
View File
@@ -267,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 });
@@ -2716,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." },
});
});
});
+7
View File
@@ -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");
});
});
+136
View File
@@ -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");
+37 -11
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;
}
@@ -447,6 +460,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,
@@ -597,14 +620,17 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again.
* @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>}>}
*/
+968 -369
View File
File diff suppressed because it is too large Load Diff
+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);
+28
View File
@@ -419,6 +419,34 @@ describe("Max on an ETH send", () => {
expect(text("flash-msg")).toBe("");
});
test("fills nothing in when the holding was changed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI, [tokenRow(10n ** 18n)]);
// Opened from the home screen, where the dropdown picks the holding.
openSend(null);
el("send-token").value = "ETH";
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-token").value = TOKEN;
el("send-token").handlers.get("change")();
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("keeps an amount typed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-amount").value = "0.5";
el("send-amount").handlers.get("input")();
release();
await pressed;
expect(el("send-amount").value).toBe("0.5");
expect(text("flash-msg")).toBe("");
});
test("fills in once the held fee estimate arrives with nothing changed", async () => {
await refreshWith(BALANCE_WEI);
openSend();
-2
View File
@@ -253,8 +253,6 @@ async function bootPopup(stored, options) {
formatUsd: () => "",
formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }),
getWalletValue: () => ({ usd: null, partial: false }),
getTotalValue: () => ({ usd: null, partial: false }),
}));
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
+159
View File
@@ -0,0 +1,159 @@
// A transaction's time is written by isoDate() and timeAgo() in
// src/popup/views/helpers.js on every screen that shows one (README, Display
// Consistency; https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail
// and AddressToken used to define their own copies, so a fix to the shared pair
// would not have reached them.
//
// The pair is replaced before the views are loaded, because a view takes it
// when it loads. A view that writes the time with a copy of its own shows the
// real time instead of the replacement.
//
// Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The transaction detail view fetches on-chain details after drawing; an
// answer that is not ok leaves the drawn lines as they are.
debugFetch: async () => ({ ok: false }),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const helpers = require("../src/popup/views/helpers");
jest.spyOn(helpers, "isoDate").mockReturnValue("SHARED-ISO-DATE");
jest.spyOn(helpers, "timeAgo").mockReturnValue("SHARED-TIME-AGO");
const { state } = require("../src/shared/state");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
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);
}
// A transaction FROM sent, as the history lists hold it.
function historyTx() {
return {
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
describe.each([
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => {
test("shows the time written by the shared isoDate() and timeAgo()", async () => {
mockHistory = [historyTx()];
open();
// The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0));
const html = node(listId).innerHTML;
expect(html).toContain('title="SHARED-ISO-DATE"');
expect(html).toContain(">SHARED-TIME-AGO<");
});
});
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
transactionDetail.show(historyTx());
const html = node("tx-detail-time").innerHTML;
expect(html).toContain("SHARED-ISO-DATE");
expect(html).toContain("(SHARED-TIME-AGO)");
});
+294 -2
View File
@@ -4,8 +4,16 @@
// already in storage: the import that created it ran before the refusal
// existed. Such a wallet used to sign for the wrong tree and now throws on the
// send screen instead. These tests pin down that it is named and explained in
// the wallet list, that nothing on the way there throws, and that a wallet
// imported from a real master key is untouched by any of it.
// the wallet list, that every control leading to a signature or to the private
// key refuses it before asking for a password, that nothing on the way there
// throws, and that a wallet imported from a real master key is untouched by
// any of it.
// Mocked so that no password has to be hashed: the controls below are checked
// for whether they decrypt at all.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const { HDNodeWallet, Mnemonic } = require("ethers");
@@ -237,6 +245,290 @@ describe("the wallet list", () => {
});
});
// A minimal DOM for driving the popup views: any element exists on first
// lookup, and click() runs the listeners a view attached to it.
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
title: "",
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"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// getSignerForAddress refuses this wallet's key, but only once the password
// has been typed and spent, and the screens report that refusal as a wrong
// password or a failed send. So every control that leads to it refuses first.
describe("every way to a signature or the private key refuses a defective wallet first", () => {
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
let state;
let decryptWithPassword;
let home;
let addressDetail;
let addressToken;
let approval;
let confirmTx;
let address;
let shortMessage;
// What the background answers when the approval window asks which
// approval it was opened for, and every message the popup sent it.
let approvalDetails;
let sent;
beforeAll(() => {
state = require("../src/shared/state").state;
decryptWithPassword =
require("../src/shared/vault").decryptWithPassword;
home = require("../src/popup/views/home");
addressDetail = require("../src/popup/views/addressDetail");
addressToken = require("../src/popup/views/addressToken");
approval = require("../src/popup/views/approval");
confirmTx = require("../src/popup/views/confirmTx");
});
beforeEach(() => {
const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check.
broken.addresses[0].balance = "1.0000";
broken.addresses[0].tokenBalances = [];
address = broken.addresses[0].address;
shortMessage = walletDefect(broken).shortMessage;
approvalDetails = null;
sent = [];
globalThis.document = makeDocument();
globalThis.window = { close: () => {} };
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
reply(
msg.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: null,
);
},
},
};
// What the wallet's stored secret decrypts to: the account-level key
// it was imported from.
decryptWithPassword.mockReset();
decryptWithPassword.mockResolvedValue(accountXprv(VECTOR_PHRASE));
state.wallets = [broken];
state.activeAddress = address;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
state.viewStack = [];
});
afterEach(() => {
state.wallets = [];
state.activeAddress = null;
state.selectedWallet = null;
state.selectedAddress = null;
state.selectedToken = null;
});
test("Send on the main screen", async () => {
state.currentView = "main";
home.init({});
await click("btn-main-send");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("main");
});
test("Send on the address screen", async () => {
state.currentView = "address";
addressDetail.init({});
await click("btn-send");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("address");
});
test("Export Private Key on the address screen", async () => {
state.currentView = "address";
addressDetail.init({});
await click("btn-export-privkey");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("address");
});
test("Send on a token's screen", async () => {
state.currentView = "address-token";
addressToken.init({});
await click("btn-address-token-send");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("address-token");
});
// The popup reopens onto this screen from a saved view, so the Send
// buttons above are not the only way onto it. The screen is not drawn,
// because drawing it starts a fee estimate against the network; with a
// decrypt that fails, a handler without the check stops at the password
// instead of going on to a transaction that was never set up.
test("Send on the confirmation screen", async () => {
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
state.currentView = "confirm-tx";
confirmTx.init({});
node("confirm-tx-password").value = "any password";
await click("btn-confirm-send");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(node("confirm-tx-password-error").textContent).toBe(
shortMessage,
);
});
async function openTxApproval() {
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
approvedTx: {
from: address,
to: RECIPIENT,
value: "0x0",
data: "0x",
chainId: 1,
nonce: 0,
gasLimit: "21000",
maxFeePerGas: "1000000000",
},
};
approval.init({});
await approval.show(1);
}
async function openSignApproval() {
approvalDetails = {
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
// "Hello", as the hex a page sends.
signParams: {
method: "personal_sign",
message: "0x48656c6c6f",
from: address,
},
};
approval.init({});
await approval.show(1);
}
test("the transaction approval screen says so and disables Approve", async () => {
await openTxApproval();
expect(node("approve-tx-error").textContent).toBe(shortMessage);
expect(node("btn-approve-tx").disabled).toBe(true);
});
// The stub runs a disabled button's listener, which a browser would not:
// what is asked here is whether the handler refuses on its own.
test("Approve on the transaction approval screen does not decrypt", async () => {
await openTxApproval();
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(sent.map((msg) => msg.type)).not.toContain(
"AUTISTMASK_TX_RESPONSE",
);
expect(node("approve-tx-error").textContent).toBe(shortMessage);
});
test("the signature approval screen says so and disables Approve", async () => {
await openSignApproval();
expect(node("approve-sign-error").textContent).toBe(shortMessage);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("Approve on the signature approval screen does not decrypt", async () => {
await openSignApproval();
node("approve-sign-password").value = "any password";
await click("btn-approve-sign");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(sent.map((msg) => msg.type)).not.toContain(
"AUTISTMASK_SIGN_RESPONSE",
);
expect(node("approve-sign-error").textContent).toBe(shortMessage);
});
});
describe("no path throws an unhandled error for a defective wallet", () => {
test("address derivation from the stored xpub still works", () => {
// The stored xpub is at a non-standard depth but is a valid extended