Compare commits

..
Author SHA1 Message Date
sneak b4ae645695 harden: show a personal message's hex and its text in byte order, hidden characters marked (closes #403)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
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 19:26:52 +00:00
66 changed files with 1054 additions and 4861 deletions
-3
View File
@@ -3,9 +3,6 @@ on: [push]
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bounds script/cibuild, a cold-cache build included, so a hang frees
# the shared runner. README.md "In CI" has the measured times.
timeout-minutes: 10
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+5 -12
View File
@@ -22,10 +22,11 @@ on: [push]
# These jobs REPORT, they do not gate. Whether a check blocks a merge is # These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure # Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard # here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked while reports of the # block. Making e2e-chrome a required check is blocked on the measured
# Chrome suite failing under load are still open; the "In CI" section of # flake in the dApp signing wait -- two of six runs of unmutated code on a
# README.md names them. A gate that fails at random teaches people to merge # loaded machine -- tracked as
# past red. # https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
# #
# Nothing here may pass vacuously. There is no continue-on-error and no # Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the # `|| true`. Both scripts exit non-zero when docker is missing, when the
@@ -35,10 +36,6 @@ on: [push]
jobs: jobs:
e2e-chrome: e2e-chrome:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Chrome
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -46,10 +43,6 @@ jobs:
e2e-firefox: e2e-firefox:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Firefox
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
timeout-minutes: 15
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+1
View File
@@ -2,3 +2,4 @@ node_modules/
yarn.lock yarn.lock
dist/ dist/
release/ release/
.claude/
+86 -210
View File
@@ -342,11 +342,6 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
offline; unrecognised outbound requests are reported as failures rather than offline; unrecognised outbound requests are reported as failures rather than
silently allowed. 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 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 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 rather than only degrading Settings: that the screen renders populated — the
@@ -376,12 +371,6 @@ 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 what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only. 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 It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval 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 popup all have to work together. A local test page is served by the route
@@ -392,13 +381,11 @@ handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
the runner and compared against the active address, the transaction assertions the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction` run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, a prompt required to return a rejection to the page rather than hang or resolve, and the
raised while another approval window has focus is required to open a window of password is required to be absent from every message the approval window sends
its own, and the password is required to be absent from every message the to the background — with the message that would carry it required to be present,
approval window sends to the background — with the message that would carry it so that check cannot pass by observing nothing. That last one is the standing
required to be present, so that check cannot pass by observing nothing. That floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
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 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 throughout, so this is **not** a real dApp against a real network with real
@@ -475,11 +462,10 @@ 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 `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. real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, the StateRecovery screen (the same cases as the Chrome It covers popup load, wallet creation through the UI, the Add Token screen, and
suite), wallet creation through the UI, the Add Token screen, and the four dApp the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and `eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
a closed approval window rejecting with EIP-1193 4001 — driven through the real — driven through the real content script, background page and approval windows.
content script, background page and approval windows.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has 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 **no npm dependencies at all**: it is built on global `fetch` and
@@ -633,30 +619,24 @@ 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 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. blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. No report of the Chrome suite That is not only a statement about configuration. The Chrome suite is
**failing under load** is open now, but it has failed that way before, so a red **measurably flaky under load** — two of six runs of unmutated code on a busy
`e2e-chrome` is read before it is believed. Do not answer one with a retry machine lost the approval popup out from under the dApp signing wait, always in
wrapper: a suite that reruns until it is green stops being evidence. 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.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no 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 `|| 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 build fails, and when the browser fails to start; the Chrome harness aborts the
suite outright if its network interception is not in effect. suite outright if its network interception is not in effect.
Measured on this repo's runner in the green runs of early October 2026, from a Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
warm docker cache to a cold one: `check` 49s to 3m37s, `e2e-chrome` 1m44s to the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
4m48s, and `e2e-firefox` 31s to 4m07s. A cold cache adds three to four minutes once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
to each job, spent rebuilding its image: reinstalling dependencies and, for Firefox and geckodriver downloads the same way.
`e2e-firefox`, installing Firefox, geckodriver and their system libraries. Those
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
in error. `make test-e2e` now takes 3m51s locally with its image cached, so a
cold `e2e-chrome` run comes to about seven minutes.
Every job has a `timeout-minutes` cap, so a hung build or browser ends the job
instead of holding the shared runner: `check` 10 minutes, `e2e-firefox` 15 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.
### Element id guard (part of `make check`) ### Element id guard (part of `make check`)
@@ -715,7 +695,6 @@ src/
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only) 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 prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
@@ -822,15 +801,13 @@ discoverable.
on critical screens and when space is available to allow users to disambiguate on critical screens and when space is available to allow users to disambiguate
addresses visually, as a security feature. addresses visually, as a security feature.
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for - **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
styling, and no `style="..."` attributes, which the styling. Tailwind is configured with a minimal monochrome palette. This keeps
[Content Security Policy](#content-security-policy) refuses. Tailwind is the styling co-located with the markup and eliminates CSS file management. The
configured with a minimal monochrome palette. This keeps the styling handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
co-located with the markup and eliminates CSS file management. The handful of carries the copy feedback animation, and `.am-address` carries the rule that
classes in `styles/main.css` are not styling: `.copy-flash-*` carries the copy an address never wraps. Both are invariants that hold in every place they
feedback animation, and `.am-address` carries the rule that an address never appear, and spelling either out as repeated utilities is how one of those
wraps. Both are invariants that hold in every place they appear, and spelling places drifts away from the rest.
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 - **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 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 would add bundle size, build complexity, and attack surface for no benefit at
@@ -864,26 +841,10 @@ something when you click it.
The same data must be formatted identically everywhere it appears. Token and ETH 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 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 USDT") in balance lists, transaction lists, send confirmations, and approval
screens. A transaction's time includes both an ISO datetime and a humanized screens. Timestamps include both an ISO datetime and a humanized relative age
relative age, written by `isoDate()` and `timeAgo()` in wherever shown. If a formatting rule applies in one place, it applies in every
`src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is place. Users should never see the same value rendered differently on two
in UTC when the UTC Timestamps setting is on. If a formatting rule applies in screens.
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 **Specific Exception — Truncation:** On some non-critical display locations, we
may truncate _a small number_ of characters from the middle of an address solely may truncate _a small number_ of characters from the middle of an address solely
@@ -926,9 +887,7 @@ 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 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 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 minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
shows is a V4 exact-in `amountIn` of zero and shows is a V4 exact-in `amountIn` of zero.
`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 The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -967,10 +926,7 @@ 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 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 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 amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`. A source's answer counts only if it is a whole still shown as `Unlimited`.
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 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 sources are read as authoritative, so a value written into one of them cannot be
@@ -1021,8 +977,7 @@ read:
exact-out step, whichever step set the line, including the `WRAP_ETH` of a 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, 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 not necessarily all of it; the wait, success and error screens show it with
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited` and `All available (V4 open delta)` keep their
`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 wording. When a V2 exact-out step sets `Min. received`, that line shows its
`amountOut`, the exact amount it buys. `amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
@@ -1033,22 +988,11 @@ read:
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its 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 `amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action. 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 - `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 minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before `BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance` [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known. The router passes a balance read `0.0000` when the token's scale was known.
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 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 reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1061,12 +1005,6 @@ 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 the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it. 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 #### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the Prices are fetched for the top 25 tokens only, so an address can hold assets the
@@ -1145,21 +1083,15 @@ 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 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 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 claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. A token's holder count is and the send-screen token selector, not this list. `fetchTokenBalances()` stores
unknown when the explorer reports none, or reports anything other than a whole every nonzero holding of a token it admits, however small, but a holding below
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()` 0.000001 is left out of the balance lists, the send-screen token selector, the
in `src/shared/holders.js`). This list does not take an unknown count as 1,000 address total and the remove-address warning (`isBelowOneMillionth()` in
or more, so such a token is shown only when it is on the bundled list or `src/shared/amountDisplay.js`). The Send and confirmation screens show it when
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it its token is the one being sent. Tracked tokens with a zero balance are listed
admits, however small, but a holding below 0.000001 is left out of the balance as well while "Show tracked tokens with zero balance" is on.
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 #### Stored state and its version
@@ -1179,18 +1111,16 @@ because bumping for one would send every older install to StateRecovery for
nothing. nothing.
Every read of the record goes through `assertStateUsable()` first, on the raw Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` and every `saveState()` for the bytes, before normalization: `loadState()` for the popup and `getState()` for
popup, and `getState()` for the background. It refuses a record that is not an the background. It refuses a record that is not an object, a `schemaVersion`
object, a `schemaVersion` this build does not understand (a newer one included), this build does not understand (a newer one included), a `wallets` that is not a
a `wallets` that is not a list of wallet records with address records in them, list of wallet records with address records in them, and a `networkId` that is
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is not a network in `src/shared/networks.js`. Refusing is the whole point — a
the whole point — a record the wallet cannot vouch for is never normalized, record the wallet cannot vouch for is never normalized, never written back, and
never written back, and never half-loaded. The popup shows StateRecovery, never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
whether it finds the record unreadable when it opens or at a save while it is (`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the saved data cannot be read and that nothing was signed or sent, rather than the
spec leaves unassigned) saying the saved data cannot be read and that nothing generic `-32603` every request used to answer.
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 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 gated, and the floor is not the same for every field. Some are type-checked as a
@@ -1234,9 +1164,8 @@ now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the `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 popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor `src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission — and the wallet must never look healthy covers — a quota, a revoked permission, a record a newer build wrote — and the
while that is true. A save that fails because the stored record fails the gate, wallet must never look healthy while that is true.
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 The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's `state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
@@ -1468,9 +1397,7 @@ view would leave a wallet one click from deletion.
- Send / Receive buttons - Send / Receive buttons
- Token contract well (ERC-20 only): full contract address (tap to copy, - Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project etherscan link) plus name, symbol, decimals, holder count and project
website where known. The "Holders:" row is left out, not shown as 0, when website where known
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) - Token-filtered transaction list (only this token's transfers)
- **Transitions**: - **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static - "Send" → **Send** (token locked: the dropdown is replaced by a static
@@ -1492,17 +1419,6 @@ view would leave a wallet one click from deletion.
- Amount input with current balance display, which reads - Amount input with current balance display, which reads
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as `Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale) 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 - "Review" button, disabled until the recipient validates
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
@@ -1519,14 +1435,7 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only) - Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title - From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name - To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max" - Amount: value + symbol (USD in parentheses)
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)` - Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown for a token whose scale is unknown
- Network fee: "Estimating..." then two lines, or "Unable to estimate", - Network fee: "Estimating..." then two lines, or "Unable to estimate",
@@ -1654,9 +1563,7 @@ view would leave a wallet one click from deletion.
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- Type: transaction classification — one of: Native ETH Transfer, ERC-20 - Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
the token reports.
- Status: "Success" or "Failed" - Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link; - From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available ENS name if available
@@ -1830,10 +1737,7 @@ view would leave a wallet one click from deletion.
new password — and, in bold, that without that phrase written down the new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever deletion loses everything the wallet holds, forever
- That the other wallets are not touched - That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back. A name - The wallet's name, and a text input asking for it to be typed back
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 - Error line
- "Delete This Wallet Forever" button - "Delete This Wallet Forever" button
- **Transitions**: - **Transitions**:
@@ -1841,9 +1745,9 @@ view would leave a wallet one click from deletion.
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED` the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match, or the field is empty) - "Delete This Wallet Forever" (name does not match) → "That is not the name
→ "That is not the name of this wallet. Type <name> to confirm." on of this wallet. Type <name> to confirm." on the error line, nothing
the error line, nothing deleted deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet - "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather 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 than parent and child: nothing is pushed on the way here, so both have
@@ -1852,13 +1756,8 @@ view would leave a wallet one click from deletion.
secret protects nobody: an attacker at the popup who wants the wallet gone can 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 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 forgot it. The typed name is a check that the user knows which wallet they are
on, not a secret, so it is matched as the user can see it: letter case, on, not a secret, so it is matched with surrounding spaces and letter case
surrounding spaces and repeated inner spaces are ignored, and characters that ignored.
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 - Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material. by accident must not land on a screen whose button erases key material.
@@ -1983,9 +1882,7 @@ view would leave a wallet one click from deletion.
`eth_sendTransaction` arriving while one is unanswered is refused with `eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens 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 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 one is answered.
in; if that was another approval window, or the browser refuses the centred
position, the browser picks the position.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
@@ -2078,19 +1975,9 @@ view would leave a wallet one click from deletion.
#### StateRecovery (`state-recovery`) #### StateRecovery (`state-recovery`)
- **When**: the stored profile fails `assertStateUsable()`. At open, that is - **When**: `loadState()` refused the stored profile, so the popup has no
`loadState()` refusing it, so the popup has no profile at all. While the popup profile at all. It is the only screen reached without one, and the only one
is open, on any screen, it is a save refusing it: every `saveState()` reads that never appears during ordinary use.
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 - **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 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. internal error, and no reset or wipe control existed anywhere in the product.
@@ -2117,20 +2004,16 @@ view would leave a wallet one click from deletion.
Nothing was erased." on the error line Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this - **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 screen is up, because every screen behind it renders from the profile that
could not be read. `showView()` is not used to raise it, for the same reason: could not be read, and `showView()` is not used to raise it for the same
it reads and writes the state singleton. Under an open popup the screen is reason — it reads and writes the state singleton.
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 - **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 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 no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses, 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 and for the same reason: there is no password to gate this with, since there
is no profile to check one against. is no profile to check one against.
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record - Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
can become readable again under an open popup, erased in another window, and nothing on this path writes state at all.
the next save from that popup then succeeds. A popup opened after that opens
normally.
### External Services ### External Services
@@ -2223,7 +2106,7 @@ a bare string in `manifest/firefox.json` (MV2):
``` ```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
style-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
base-uri 'none' base-uri 'none'
``` ```
@@ -2235,17 +2118,15 @@ 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 `src/shared/html.js`); this is the second line, so an escape that does slip
cannot reach the network. cannot reach the network.
`style-src 'self'` admits the stylesheet and nothing inline: both browsers Four directives are looser than `'self'`, each for a reason that does not
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: 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 - `img-src data:` — identicons are generated in the popup by
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs. `ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local - `connect-src https: http:` — the RPC endpoint is user-configurable and a local
@@ -2441,8 +2322,7 @@ indexes it as a real token transfer.
act on and what the user believes they own rather than what the history 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`, 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 so they cannot answer the question differently. A symbol the list maps to no
contract at all — the native asset's labels: `"ETH"` and every network's contract at all — `"ETH"`, the native asset, is the only one — may be borne by
`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 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 real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
it. it.
@@ -2451,8 +2331,7 @@ indexes it as a real token transfer.
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.
Legitimate tokens have substantial holder counts; poisoning tokens typically Legitimate tokens have substantial holder counts; poisoning tokens typically
have zero. This catches new poisoning contracts that use novel symbols not in have zero. This catches new poisoning contracts that use novel symbols not in
the known token list. A transfer whose token's holder count is unknown (see the known token list.
Data Model) is kept: only a reported count below 1,000 hides it.
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud - **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
contract addresses. Token transfers involving these contracts are filtered contract addresses. Token transfers involving these contracts are filtered
@@ -2462,9 +2341,7 @@ indexes it as a real token transfer.
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are - **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 excluded from the token selector on the send screen. This prevents users from
accidentally interacting with a spoofed token that appeared in their balance accidentally interacting with a spoofed token that appeared in their balance
via a fake Transfer event. A token whose holder count is unknown is kept in via a fake Transfer event.
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 - **Dust transaction filtering**: A second wave of the same attack used real
native ETH transfers instead of fake tokens. Transaction native ETH transfers instead of fake tokens. Transaction
@@ -2488,9 +2365,8 @@ indexes it as a real token transfer.
both cases identically to the history. The fraud contract blocklist is applied 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 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 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). An unknown list's own 1,000-holder floor is unconditional (see Data Model). The dust
holder count passes the history and send-selector filters but not that floor. threshold applies to the transaction history alone.
The dust threshold applies to the transaction history alone.
#### Phishing Domain Protection #### Phishing Domain Protection
+1 -276
View File
@@ -45,273 +45,6 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 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: 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 - 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 are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
showed only the decoded text, with bidirectional and zero-width characters showed only the decoded text, with bidirectional and zero-width characters
@@ -322,15 +55,7 @@ but the review is broader than any of them.
characters that paint nothing are shown as `U+XXXX` marks, and a message that 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 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. "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 - 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 ([#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 every request's full URL and body, so an RPC endpoint with a key in its path
+2 -10
View File
@@ -240,9 +240,7 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
2. Select what to send (ETH, or any ERC-20 token with a balance on this address 2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters). that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18 4. Enter the amount.
decimal places, or your ETH balance minus the amount reserved for the network
fee.
5. Click "Review" to see the confirmation screen. 5. Click "Review" to see the confirmation screen.
The confirmation screen shows: The confirmation screen shows:
@@ -335,13 +333,7 @@ it is hidden from your transaction history and from the send token list.
from transaction history and the send token list, and are left out of your from transaction history and the send token list, and are left out of your
balances unless they are on the bundled known-token list or you added them balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. When the explorer reports no holder for address poisoning typically have zero.
count for a token, or reports something other than a whole number in plain
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
token from your transaction history or the send token list, and it does not get
a token into your balances either: such a token is listed only if it is on the
bundled known-token list or you added it yourself. The screen you reach by
clicking a token balance shows a "Holders:" line only when the count is known.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
+1 -1
View File
@@ -7,7 +7,7 @@
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": { "content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; 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' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
}, },
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; 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' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
"32": "icons/icon32.png", "32": "icons/icon32.png",
+4 -27
View File
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
} }
} }
// Open approval in a separate popup window, unless it is no longer pending. // Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically, // This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals. // not from a user gesture) and the fallback for site-connection approvals.
// Never rejects. Its callers raise it from inside a Promise executor and drop // Never rejects. Its callers raise it from inside a Promise executor and drop
@@ -437,13 +437,7 @@ async function openApprovalWindow(id) {
width: popupWidth, width: popupWidth,
height: popupHeight, height: popupHeight,
}; };
// Centred on a browser window only. The last focused window can be if (currentWin) {
// another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 360x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position.
if (currentWin && currentWin.type === "normal") {
opts.left = Math.round( opts.left = Math.round(
currentWin.left + (currentWin.width - popupWidth) / 2, currentWin.left + (currentWin.width - popupWidth) / 2,
); );
@@ -452,32 +446,15 @@ async function openApprovalWindow(id) {
); );
} }
// Already answered: a site-connection prompt decided before the toolbar
// popup raised for it had loaded, whose openPopup() rejects only now.
if (!pendingApprovals[id]) return;
let win = null; let win = null;
try { try {
win = await windowsCreate(opts); win = await windowsCreate(opts);
} catch (e) { } catch (e) {
// The promise namespace reports the failure by rejecting where the // The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both // callback namespace reported it by handing back no window; both land
// leave win null. // on the !win branch below, which settles the approval.
log.errorf("could not open the approval window:", e); log.errorf("could not open the approval window:", e);
} }
// The browser also refuses a centred position that is too far off screen,
// as it is over a browser window near the screen edge. Asked again
// without a position, it places the window itself. If that fails too,
// the !win branch below settles the approval.
if (!win && opts.left !== undefined) {
delete opts.left;
delete opts.top;
try {
win = await windowsCreate(opts);
} catch (e) {
log.errorf("could not open the approval window:", e);
}
}
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (!approval) { if (!approval) {
+103 -56
View File
@@ -6,10 +6,7 @@
<title>AutistMask</title> <title>AutistMask</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<!-- Chrome gives extension pages a stylesheet of its own that sets the <body class="bg-bg text-fg font-mono text-sm">
font on body, and a Tailwind class beats it only when marked
important: hence font-mono! rather than font-mono. -->
<body class="bg-bg text-fg font-mono! text-sm">
<div id="app" class="p-2 pr-5 overflow-x-hidden"> <div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ --> <!-- ============ GLOBAL TITLE BAR ============ -->
<div <div
@@ -110,7 +107,8 @@
</div> </div>
<div <div
id="add-wallet-phrase-warning" id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 invisible" class="text-xs mb-2 border border-border border-dashed p-2"
style="visibility: hidden"
> >
Write these words down and keep them safe. Anyone with Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet them can take your funds; if you lose them, your wallet
@@ -261,7 +259,10 @@
<!-- recent transactions across all addresses --> <!-- recent transactions across all addresses -->
<div> <div>
<div class="font-bold bg-section py-1 px-2 -mx-2"> <div
class="font-bold bg-section py-1 px-2"
style="margin-left: -0.5rem; margin-right: -0.5rem"
>
Recent Transactions Recent Transactions
</div> </div>
<div id="home-tx-list"> <div id="home-tx-list">
@@ -269,7 +270,7 @@
</div> </div>
</div> </div>
<div class="py-1 -mx-2">&nbsp;</div> <div class="py-1" style="margin: 0 -0.5rem">&nbsp;</div>
<div class="text-xs text-muted"> <div class="text-xs text-muted">
<span <span
@@ -405,7 +406,8 @@
</p> </p>
<div <div
id="export-privkey-flash" id="export-privkey-flash"
class="text-xs mb-2 min-h-[1.25rem] invisible" class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div id="export-privkey-password-section" class="mb-2"> <div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -537,7 +539,8 @@
/> />
<div <div
id="send-to-error" id="send-to-error"
class="text-xs min-h-[1.25rem] text-[#cc0000]" class="text-xs"
style="min-height: 1.25rem; color: #cc0000"
></div> ></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@@ -548,20 +551,12 @@
class="text-xs text-muted" class="text-xs text-muted"
></span> ></span>
</div> </div>
<div class="flex gap-1"> <input
<input type="text"
type="text" id="send-amount"
id="send-amount" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg" placeholder="0.0"
placeholder="0.0" />
/>
<button
id="btn-send-max"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Max
</button>
</div>
</div> </div>
<button <button
id="btn-send-review" id="btn-send-review"
@@ -613,7 +608,7 @@
<div class="text-xs text-muted mb-1">Your balance</div> <div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div> <div id="confirm-balance" class="text-xs"></div>
</div> </div>
<div id="confirm-fee" class="mb-3 invisible"> <div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div class="text-xs text-muted mb-1">Network fee</div> <div class="text-xs text-muted mb-1">Network fee</div>
<div id="confirm-fee-amount" class="text-xs"></div> <div id="confirm-fee-amount" class="text-xs"></div>
<!-- Holds its one line of space from the first paint, so <!-- Holds its one line of space from the first paint, so
@@ -621,13 +616,22 @@
nothing. The placeholder is never seen. --> nothing. The placeholder is never seen. -->
<div <div
id="confirm-fee-reserve" id="confirm-fee-reserve"
class="text-xs text-muted invisible" class="text-xs text-muted"
style="visibility: hidden"
> >
reserve pending reserve pending
</div> </div>
</div> </div>
<div id="confirm-warnings" class="mb-2 invisible"></div> <div
<div id="confirm-recipient-warning" class="mb-2 invisible"> id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-warning"
class="mb-2"
style="visibility: hidden"
>
<div <div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
> >
@@ -636,13 +640,24 @@
Double-check the address before sending. Double-check the address before sending.
</div> </div>
</div> </div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div <div
id="confirm-contract-warning" id="confirm-contract-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible" class="mb-2"
></div> style="visibility: hidden"
<div id="confirm-burn-warning" class="mb-2 invisible"> >
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient is a smart contract. Sending ETH
or tokens directly to a contract may result in permanent
loss of funds.
</div>
</div>
<div
id="confirm-burn-warning"
class="mb-2"
style="visibility: hidden"
>
<div <div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
> >
@@ -650,7 +665,11 @@
here are permanently destroyed and cannot be recovered. here are permanently destroyed and cannot be recovered.
</div> </div>
</div> </div>
<div id="confirm-etherscan-warning" class="mb-2 invisible"> <div
id="confirm-etherscan-warning"
class="mb-2"
style="visibility: hidden"
>
<div <div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500" class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
> >
@@ -660,26 +679,31 @@
</div> </div>
<div <div
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]" class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem"
></div> ></div>
<div <div
id="confirm-amount-fee-error" id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
> >
Your balance does not cover this amount plus the network Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount. fee. Please go back and send a smaller amount.
</div> </div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div <div
id="confirm-gas-error" id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs"
></div> 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>
<!-- Its sentence names why the fee could not be estimated, <!-- Its sentence names why the fee could not be estimated,
so show() in confirmTx.js sets it. --> so show() in confirmTx.js sets it. -->
<div <div
id="confirm-fee-unknown-error" id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
@@ -691,7 +715,8 @@
</div> </div>
<div <div
id="confirm-tx-password-error" id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem] invisible" class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-confirm-send" id="btn-confirm-send"
@@ -806,7 +831,8 @@
</button> </button>
<div <div
id="receive-erc20-warning" id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3 invisible" class="text-xs border border-border border-dashed p-2 mt-3"
style="visibility: hidden"
></div> ></div>
</div> </div>
@@ -834,7 +860,8 @@
</div> </div>
<div <div
id="add-token-info" id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible" class="text-xs text-muted mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs text-muted" <label class="block mb-1 text-xs text-muted"
@@ -1020,7 +1047,8 @@
type="text" type="text"
inputmode="numeric" inputmode="numeric"
id="settings-dust-threshold" id="settings-dust-threshold"
class="border border-border p-1 text-xs bg-bg text-fg w-[10ch]" class="border border-border p-1 text-xs bg-bg text-fg"
style="width: 10ch"
/> />
<span class="text-xs text-muted">gwei</span> <span class="text-xs text-muted">gwei</span>
</div> </div>
@@ -1097,7 +1125,8 @@
<div <div
id="settings-debug-well" id="settings-debug-well"
class="bg-well p-3 mx-1 mb-3 hidden" class="bg-well p-3 mx-1 mb-3"
style="display: none"
> >
<h3 class="font-bold mb-1">Debug</h3> <h3 class="font-bold mb-1">Debug</h3>
<label <label
@@ -1125,7 +1154,8 @@
</p> </p>
<div <div
id="delete-wallet-flash" id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible" class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -1198,7 +1228,8 @@
</div> </div>
<div <div
id="delete-wallet-lost-flash" id="delete-wallet-lost-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible" class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-delete-wallet-lost-confirm" id="btn-delete-wallet-lost-confirm"
@@ -1253,7 +1284,8 @@
</p> </p>
<div <div
id="delete-address-flash" id="delete-address-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible" class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-delete-address-confirm" id="btn-delete-address-confirm"
@@ -1282,7 +1314,8 @@
</div> </div>
<div <div
id="show-phrase-flash" id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible" class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<div id="show-phrase-password-section" class="mb-2"> <div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -1364,7 +1397,8 @@
/> />
<div <div
id="settings-addtoken-info" id="settings-addtoken-info"
class="text-xs text-muted mt-1 min-h-[1.25rem] invisible" class="text-xs text-muted mt-1 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-settings-addtoken-manual" id="btn-settings-addtoken-manual"
@@ -1597,7 +1631,8 @@
</div> </div>
<div <div
id="approve-tx-error" id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
@@ -1633,7 +1668,15 @@
<div <div
id="approve-sign-danger-warning" id="approve-sign-danger-warning"
class="mb-3 p-2 text-xs font-bold invisible min-h-[1.25rem] bg-[#fee2e2] text-[#991b1b] border-2 border-[#dc2626] rounded-[6px]" 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;
"
></div> ></div>
<div class="mb-3"> <div class="mb-3">
@@ -1650,7 +1693,8 @@
<div class="text-xs text-muted mb-1">Message</div> <div class="text-xs text-muted mb-1">Message</div>
<div <div
id="approve-sign-message" id="approve-sign-message"
class="text-xs break-all max-h-48 overflow-y-auto" class="text-xs break-all"
style="max-height: 12rem; overflow-y: auto"
></div> ></div>
</div> </div>
@@ -1658,7 +1702,8 @@
<div class="text-xs text-muted mb-1">Raw data</div> <div class="text-xs text-muted mb-1">Raw data</div>
<div <div
id="approve-sign-hex" id="approve-sign-hex"
class="text-xs break-all max-h-24 overflow-y-auto" class="text-xs break-all"
style="max-height: 6rem; overflow-y: auto"
></div> ></div>
</div> </div>
@@ -1672,7 +1717,8 @@
</div> </div>
<div <div
id="approve-sign-error" id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
@@ -1796,7 +1842,8 @@
</div> </div>
<div <div
id="state-recovery-flash" id="state-recovery-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible" class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div> ></div>
<button <button
id="btn-state-recovery-reset" id="btn-state-recovery-reset"
+2 -27
View File
@@ -50,10 +50,6 @@ function renderWalletList() {
let refreshInFlight = false; let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the
// recovery screen: there is no profile left to refresh.
let refreshTimer = null;
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
@@ -159,28 +155,7 @@ async function init() {
// reported rather than being swallowed by the save queue // reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of // (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well. // the approval-window branch below too, since that window saves as well.
// onSaveFailure(showSaveFailureBanner);
// Every save first reads the stored record and refuses it with the same
// check loadState() runs below. So a record that becomes unreadable while
// the popup is open is found by the next save, a navigation or the
// ten-second refresh, and gets the screen it would get at open
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
// screen to showView() first leaves the current screen as any navigation
// does, so a phrase, key or password on it is wiped, and from then on
// showView() shows nothing else. A later save that fails the same way,
// such as a refresh already in flight, comes back here, where both calls
// see the screen already up and do nothing. Any other failed save is a
// read or write that failed, and gets the banner without changing the
// screen.
onSaveFailure((e) => {
if (e instanceof StateUnusableError) {
clearInterval(refreshTimer);
showView("state-recovery");
stateRecovery.show(e);
} else {
showSaveFailureBanner(e);
}
});
try { try {
await loadState(); await loadState();
} catch (e) { } catch (e) {
@@ -269,7 +244,7 @@ async function init() {
renderWalletList(); renderWalletList();
restoreView(); restoreView();
doRefreshAndRender(); doRefreshAndRender();
refreshTimer = setInterval(doRefreshAndRender, 10000); setInterval(doRefreshAndRender, 10000);
} }
} }
+65 -13
View File
@@ -11,10 +11,8 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -33,8 +31,8 @@ const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the // The defect of the wallet the selected address belongs to, or null. Both the
// send and the private-key export path check it before asking for a password, // send and the private-key export path check it before asking for a password,
// so a wallet whose key getSignerForAddress refuses says so instead of failing // so a wallet that cannot derive its keys says so instead of failing after the
// after the user has typed one in. // user has typed one in.
function selectedWalletDefect() { function selectedWalletDefect() {
if (state.selectedWallet === null) return null; if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]); return walletDefect(state.wallets[state.selectedWallet]);
@@ -66,7 +64,7 @@ function show() {
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr)); const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;"; $("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
@@ -90,6 +88,62 @@ function show() {
loadTransactions(addr.address); loadTransactions(addr.address);
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
@@ -99,7 +153,6 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
@@ -181,10 +234,10 @@ function renderTransactions(txs) {
// it on the line above rather than replacing it. // it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; 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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
@@ -259,10 +312,9 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => { $("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden"); moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg"); moreBtn.classList.remove("bg-fg", "text-bg");
// This address's private key can be derived from the stored key, // There is no private key to export for an address this wallet
// but export goes through getSignerForAddress, which refuses a key // cannot derive. Without this the export screen would take a
// that is not a master key. Without this the export screen would // password and then report it as wrong.
// take a password and then report that refusal as a wrong password.
const defect = selectedWalletDefect(); const defect = selectedWalletDefect();
if (defect) { if (defect) {
showFlash(defect.shortMessage); showFlash(defect.shortMessage);
+63 -11
View File
@@ -10,17 +10,14 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
balanceLine, balanceLine,
unknownableAmount, unknownableAmount,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { const {
@@ -39,6 +36,62 @@ const { walletDefect } = require("../../shared/walletDefects");
let ctx; let ctx;
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
let currentSymbol = null; let currentSymbol = null;
@@ -53,7 +106,7 @@ function show() {
let symbol, amount, price; let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") { if (tokenId === "ETH") {
symbol = nativeCurrency(); symbol = "ETH";
amount = parseFloat(addr.balance || "0"); amount = parseFloat(addr.balance || "0");
price = getPrice("ETH"); price = getPrice("ETH");
} else { } else {
@@ -103,7 +156,7 @@ function show() {
// USD total for this token only // USD total for this token only
const usdVal = price && amount !== null ? amount * price : null; const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;"; $("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
// Single token balance line (no tokenId — not clickable here) // Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price); $("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
@@ -148,9 +201,9 @@ function show() {
if (tokenSymbol) if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null) if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
if (tokenHolders != null) if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
if (projectUrl) if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml; contractInfo.innerHTML = infoHtml;
@@ -173,7 +226,6 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
@@ -258,10 +310,10 @@ function renderTransactions(txs) {
// it on the line above rather than replacing it. // it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; 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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
+18 -24
View File
@@ -10,13 +10,9 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const { networkByChainId } = require("../../shared/networks");
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { const {
formatEther, formatEther,
formatUnits, formatUnits,
@@ -74,6 +70,17 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) { function decodeCalldata(data, toAddress) {
@@ -212,12 +219,8 @@ function showTxFee(approvedTx) {
const gasLimit = BigInt(approvedTx.gasLimit); const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same // Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. In the native currency of the network shown // fee reads the same on both.
// above, as the value is. $("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
$("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId),
);
let detail = let detail =
gasLimit.toString() + gasLimit.toString() +
@@ -261,7 +264,6 @@ function showTxApproval(details) {
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: approvedTx.chainId,
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
@@ -327,15 +329,8 @@ function showTxApproval(details) {
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: a site can switch the
// active network after this transaction is prepared and back before it is
// signed.
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
" " +
nativeCurrencyByChainId(approvedTx.chainId) +
(usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx); showTxFee(approvedTx);
@@ -822,10 +817,9 @@ function setSignButtonBusy(busy) {
} }
// Say so on the approval screen itself, and disable the approve button, when // Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose key // the address the approval was raised for belongs to a wallet whose keys
// getSignerForAddress refuses. Without this the screen would take a password // cannot be derived. Without this the screen would take a password and fail
// and fail after deriving it. Reject stays available; the wallet is not // after deriving it. Reject stays available; the wallet is not touched.
// touched.
// Returns true when it gated. // Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId, address) { function gateOnWalletDefect(errorId, buttonId, address) {
const owner = findWalletFor(address); const owner = findWalletFor(address);
+36 -111
View File
@@ -11,17 +11,14 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave, onViewLeave,
formatFee, formatFee,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
@@ -45,10 +42,10 @@ const {
FEE_UNAVAILABLE, FEE_UNAVAILABLE,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
validateTransfer, validateTransfer,
} = require("../../shared/txValidation"); } = require("../../shared/txValidation");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
let pendingTx = null; let pendingTx = null;
@@ -56,10 +53,6 @@ let pendingTx = null;
// filled in by estimateGas() when the estimate resolves or fails. // filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING; let feeStatus = FEE_PENDING;
let feeWei = null; let feeWei = null;
// The fee fields a max ETH send is signed with: those of the estimate its
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
let maxSendFees = null;
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -68,6 +61,11 @@ function restore() {
} }
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
@@ -83,30 +81,16 @@ function valueWithUsd(text, usdAmount) {
return text; return text;
} }
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the // The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the // screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped. // price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency(); const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const symbol = displaySymbol(rawSymbol); const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
@@ -114,7 +98,7 @@ function show(txInfo) {
$("confirm-type").textContent = $("confirm-type").textContent =
"ERC-20 token transfer (" + symbol + ")"; "ERC-20 token transfer (" + symbol + ")";
} else { } else {
$("confirm-type").textContent = "Native " + symbol + " transfer"; $("confirm-type").textContent = "Native ETH transfer";
} }
// Token contract section (ERC-20 only) // Token contract section (ERC-20 only)
@@ -147,11 +131,18 @@ function show(txInfo) {
); );
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); // Amount (with inline USD)
// Balance (with inline USD)
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol); const tokenPrice = getPrice(rawSymbol);
const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + symbol,
amountUsd,
);
// Balance (with inline USD)
if (isErc20) { if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero // null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is // (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -171,7 +162,7 @@ function show(txInfo) {
const bal = txInfo.balance || "0"; const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null; const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd( $("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol, truncateAmountNeverZero(bal) + " ETH",
balUsd, balUsd,
); );
} }
@@ -206,19 +197,6 @@ function show(txInfo) {
// estimate landing later never moves anything. // estimate landing later never moves anything.
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20); $("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20); $("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
// The fee-unknown message names its cause, which is also known here. // The fee-unknown message names its cause, which is also known here.
// Without the token's scale estimateGas() cannot encode the transfer, so // Without the token's scale estimateGas() cannot encode the transfer, so
@@ -267,9 +245,7 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything. // touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
@@ -282,7 +258,7 @@ function renderValidation(txInfo) {
// Messages carrying the user's own numbers are built here; the fixed // Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the // sentences live in the reserved elements in index.html, except the
// gas and fee-unknown ones, which show() sets. // fee-unknown one, which show() sets.
const messages = []; const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) { if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send."); messages.push("Please enter a valid amount to send.");
@@ -311,13 +287,9 @@ function renderValidation(txInfo) {
messages.push( messages.push(
"Insufficient balance. You have " + "Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") + truncateAmountNeverZero(txInfo.balance || "0") +
" " + " ETH but are trying to send " +
symbol +
" but are trying to send " +
txInfo.amount + txInfo.amount +
" " + " ETH.",
symbol +
".",
); );
} }
@@ -388,8 +360,8 @@ async function estimateGas(txInfo) {
} }
// What the node will require to be reserved, which is what the gate // What the node will require to be reserved, which is what the gate
// must be: the send is broadcast as a type-2 transaction priced at // must be: the send pins no fee fields, so it is broadcast as a
// maxFeePerGas, which only a max ETH send pins (see below). // type-2 transaction priced at maxFeePerGas.
const gasCostWei = feeReserveWei(gasLimit, feeData); const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) { if (gasCostWei === null) {
throw new Error("no usable gas price from the provider"); throw new Error("no usable gas price from the provider");
@@ -406,51 +378,21 @@ async function estimateGas(txInfo) {
// The fee line goes through formatFee(), as the approval screen's // The fee line goes through formatFee(), as the approval screen's
// does, so the same fee reads the same on both. // does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = $("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
"~" + formatFee(estimateWei, nativeCurrency());
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
" " + " ETH reserved";
nativeCurrency() +
" reserved";
setVisible("confirm-fee-reserve", true); setVisible("confirm-fee-reserve", true);
} else { } else {
// No spread to report: either there is no estimate, or the node // No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected // quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone. // cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee( $("confirm-fee-amount").textContent = formatFee(gasCostWei);
gasCostWei,
nativeCurrency(),
);
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
feeWei = gasCostWei; feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the
// Send screen's, and is signed with this estimate's fee fields: fees
// fetched again at signing could exceed the reserve it leaves, and the
// node would refuse it for want of funds. Where the balance no longer
// covers the fee, the amount is left as it is and the balance check
// below says so.
if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
// or gasPrice on a network with no type-2 pricing.
if (feeData.maxFeePerGas != null) {
maxSendFees = {
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
};
} else {
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
}
}
}
renderValidation(txInfo); renderValidation(txInfo);
} catch (e) { } catch (e) {
log.errorf("gas estimation failed:", e.shortMessage || e.message); log.errorf("gas estimation failed:", e.shortMessage || e.message);
@@ -464,19 +406,18 @@ async function estimateGas(txInfo) {
} }
// Populate the transaction this send describes, enforce the fee bound against // Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. Apart // the fees that were actually filled in, then sign and broadcast it. The send
// from a max ETH send, which passes its estimate's fee fields as `fees`, the // pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from // the configured RPC node answers, with nothing otherwise bounding what a
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one. // hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the // Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when // same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller // the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending. // shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) { async function populateVerifyAndSend(connectedSigner, tx) {
let request; let request;
if (tx.token === "ETH") { if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount), ...fees }; request = { to: tx.to, value: parseEther(tx.amount) };
} else { } else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner); const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the // The contract's decimals() is read to be COMPARED with the scale the
@@ -538,15 +479,6 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
const defect = walletDefect(wallet);
if (defect) {
showError("confirm-tx-password-error", defect.shortMessage);
return;
}
const password = $("confirm-tx-password").value; const password = $("confirm-tx-password").value;
if (!password) { if (!password) {
showError( showError(
@@ -556,6 +488,7 @@ function init(_ctx) {
return; return;
} }
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret; let decryptedSecret;
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
@@ -575,10 +508,6 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true; $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted"); $("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx; let tx;
try { try {
const signer = getSignerForAddress( const signer = getSignerForAddress(
@@ -589,11 +518,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
tx = await populateVerifyAndSend( tx = await populateVerifyAndSend(connectedSigner, pendingTx);
connectedSigner,
pendingTx,
maxSendFees,
);
// Best-effort: clear decrypted secret after use. // Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but // Note: JS strings are immutable; this nulls the reference but
+13 -26
View File
@@ -12,7 +12,6 @@ const {
removeWalletFromState, removeWalletFromState,
broadcastActiveChanged, broadcastActiveChanged,
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null; let lostPasswordIndex = null;
@@ -21,31 +20,21 @@ let ctx = null;
// The name shown for a wallet, and on the lost-password screen the string // The name shown for a wallet, and on the lost-password screen the string
// the user has to type back. One function so the two cannot disagree: a // the user has to type back. One function so the two cannot disagree: a
// confirmation that asks for a name other than the one on screen is // confirmation that asks for a name other than the one on screen is
// unusable. A name that shows nothing at all (only spaces, or only // unusable.
// zero-width characters) is replaced by "Wallet N" for the same reason:
// there would be nothing on screen to type back.
function displayName(walletIdx) { function displayName(walletIdx) {
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
const name = wallet && wallet.name; return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
if (name && confirmKey(name)) return name;
return "Wallet " + (walletIdx + 1);
} }
// What the typed confirmation and the wallet name are compared as. HTML // What the typed confirmation and the wallet name are compared as. HTML
// collapses runs of whitespace when it renders the name, so a wallet named // collapses runs of whitespace when it renders the name, so a wallet named
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot // "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
// see the second space and cannot type a string that matches the stored // see the second space and cannot type a string that matches the stored
// name. Characters that paint nothing, such as a zero-width space, are // name. Comparing collapsed on both sides is what keeps the confirmation
// invisible the same way and are removed first. Comparing this form on // satisfiable, on the one screen whose whole purpose is unwedging a user
// both sides is what keeps the confirmation satisfiable, on the one screen // who is already stuck. Case and surrounding space go the same way.
// whose whole purpose is unwedging a user who is already stuck. Case and
// surrounding space go the same way.
function confirmKey(name) { function confirmKey(name) {
return name return name.trim().replace(/\s+/g, " ").toLowerCase();
.replace(INVISIBLE_CHARACTERS, "")
.trim()
.replace(/\s+/g, " ")
.toLowerCase();
} }
// Drop the password from the DOM and the wallet selection from the // Drop the password from the DOM and the wallet selection from the
@@ -185,16 +174,14 @@ function init(_ctx) {
return; return;
} }
// Case, surrounding spaces, repeated inner spaces and invisible // Case, surrounding spaces and repeated inner spaces are not part
// characters are not part of the confirmation; see confirmKey(). // of the confirmation; see confirmKey(). This asks whether the
// This asks whether the user knows which wallet they are on; it is // user knows which wallet they are on; it is not a secret, and
// not a secret, and refusing "wallet 2" for "Wallet 2" would only // refusing "wallet 2" for "Wallet 2" would only teach the user to
// teach the user to distrust the control. An empty field is // distrust the control.
// refused whatever the wallet is called, so no stored name can const typed = $("delete-wallet-lost-name-input").value;
// ever be confirmed by typing nothing.
const typed = confirmKey($("delete-wallet-lost-name-input").value);
const expected = displayName(lostPasswordIndex); const expected = displayName(lostPasswordIndex);
if (typed === "" || typed !== confirmKey(expected)) { if (confirmKey(typed) !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent = $("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " + "That is not the name of this wallet. Type " +
expected + expected +
+31 -86
View File
@@ -13,12 +13,10 @@
// reasoning behind it are; it is re-exported below so views keep importing // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { formatEther } = require("ethers"); const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -54,8 +52,9 @@ const VIEWS = [
"export-privkey", "export-privkey",
"show-phrase", "show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile // Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read, never by showView() (see there), but listed so that // cannot be read. It is never reached through showView() — by then the
// every view-hiding loop covers it. // state singleton this file writes on every navigation refuses to be read
// — but it is listed so that every view-hiding loop covers it.
"state-recovery", "state-recovery",
]; ];
@@ -86,28 +85,12 @@ function hideError(id) {
el.style.visibility = "hidden"; el.style.visibility = "hidden";
} }
// Set when src/popup/index.js passes the recovery screen to showView(), and
// never cleared. Kept in memory for this popup's life, never in
// state.currentView, which is saved: a popup opened later must not inherit it.
let stateRecoveryShown = false;
function showView(name) { function showView(name) {
// The recovery screen, once up, is never replaced: work still running
// when it went up, such as a transaction wait, must not take the user off
// it or clear what they exported or typed there
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
if (stateRecoveryShown) return;
const leaving = state.currentView; const leaving = state.currentView;
if (leaving && leaving !== name) { if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving); const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave(); if (onLeave) onLeave();
} }
// Passed here only so the screen it replaces is left like any other;
// stateRecovery.show() raises it, and it is never the current view.
if (name === "state-recovery") {
stateRecoveryShown = true;
return;
}
for (const v of VIEWS) { for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`); const el = document.getElementById(`view-${v}`);
if (el) { if (el) {
@@ -269,42 +252,16 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
} }
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// the Send and confirmation screens, which send on the active network, label a
// native amount with this; a transaction already made or requested is labelled
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
// id, not its label, and stays "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the // the ETH figure through truncateAmountNeverZero(), then its USD value when the
// native currency of the network the fee is paid on, then its USD value when // ETH price is known. The USD value is of the exact fee, not of the truncated
// the ETH price is known. The USD value is of the exact fee, not of the // figure.
// truncated figure. function formatFee(wei) {
function formatFee(wei, symbol) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return ( return (
truncateAmountNeverZero(eth) + truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
" " +
symbol +
(usd ? " (" + usd + ")" : "")
); );
} }
@@ -325,7 +282,7 @@ function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd = const usd =
price && amount !== null price && amount !== null
? escapeHtml(formatUsd(amount * price)) || "&nbsp;" ? formatUsd(amount * price) || "&nbsp;"
: "&nbsp;"; : "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it // tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute. // lands inside a quoted attribute.
@@ -335,7 +292,7 @@ function balanceLine(symbol, amount, price, tokenId) {
: ""; : "";
return ( return (
`<div class="flex text-xs${clickClass}"${tokenAttr}>` + `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between w-[42ch] max-w-full">` + `<span class="flex justify-between" style="width:42ch;max-width:100%">` +
`<span>${escapeHtml(displaySymbol(symbol))}</span>` + `<span>${escapeHtml(displaySymbol(symbol))}</span>` +
`<span>${qty}</span>` + `<span>${qty}</span>` +
`</span>` + `</span>` +
@@ -346,7 +303,7 @@ function balanceLine(symbol, amount, price, tokenId) {
function balanceLinesForAddress(addr, trackedTokens, showZero) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
nativeCurrency(), "ETH",
parseFloat(addr.balance || "0"), parseFloat(addr.balance || "0"),
getPrice("ETH"), getPrice("ETH"),
"ETH", "ETH",
@@ -430,26 +387,23 @@ function truncateMiddle(str, maxLen) {
// 16 colors evenly spaced around the hue wheel (22.5° apart), // 16 colors evenly spaced around the hue wheel (22.5° apart),
// all at HSL saturation 70%, lightness 50% for uniform vibrancy. // all at HSL saturation 70%, lightness 50% for uniform vibrancy.
// Each is a whole Tailwind class: Tailwind builds only the classes it finds
// written out in the source, so the class name cannot be put together at
// runtime.
const ADDRESS_COLORS = [ const ADDRESS_COLORS = [
"bg-[#d92626]", "#d92626",
"bg-[#d96926]", "#d96926",
"bg-[#d9ac26]", "#d9ac26",
"bg-[#c2d926]", "#c2d926",
"bg-[#80d926]", "#80d926",
"bg-[#3dd926]", "#3dd926",
"bg-[#26d953]", "#26d953",
"bg-[#26d996]", "#26d996",
"bg-[#26d9d9]", "#26d9d9",
"bg-[#2696d9]", "#2696d9",
"bg-[#2653d9]", "#2653d9",
"bg-[#3d26d9]", "#3d26d9",
"bg-[#8026d9]", "#8026d9",
"bg-[#c226d9]", "#c226d9",
"bg-[#d926ac]", "#d926ac",
"bg-[#d92669]", "#d92669",
]; ];
function addressColor(address) { function addressColor(address) {
@@ -459,12 +413,7 @@ function addressColor(address) {
function addressDotHtml(address) { function addressDotHtml(address) {
const color = addressColor(address); const color = addressColor(address);
return `<span class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle shrink-0"></span>`; 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" class="inline-block rounded-[50%] [image-rendering:pixelated]">`;
} }
// Look up an address across all wallets and return its title // Look up an address across all wallets and return its title
@@ -513,9 +462,6 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// A transaction's time as every screen shows it (README, Display
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
// on, and the relative age. Views import these two; they keep no copies.
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -574,7 +520,7 @@ function timeAgo(timestamp) {
// Shared external-link icon SVG used across all views. // Shared external-link icon SVG used across all views.
const EXT_ICON = const EXT_ICON =
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` + `<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` + `<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` + `<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
@@ -714,11 +660,9 @@ module.exports = {
balanceLinesForAddress, balanceLinesForAddress,
addressHoldsFunds, addressHoldsFunds,
unknownableAmount, unknownableAmount,
nativeCurrency,
tokenLabel,
formatFee, formatFee,
addressColor,
addressDotHtml, addressDotHtml,
blockieHtml,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
addressTitle, addressTitle,
@@ -728,6 +672,7 @@ module.exports = {
renderAddressHtml, renderAddressHtml,
copyableHtml, copyableHtml,
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
EXT_ICON, EXT_ICON,
+11 -22
View File
@@ -10,17 +10,11 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { const { state, saveState, currentAddress } = require("../../shared/state");
state,
saveState,
currentAddress,
currentNetwork,
} = require("../../shared/state");
const { notify } = require("../../shared/browserApi"); const { notify } = require("../../shared/browserApi");
const { const {
updateSendBalance, updateSendBalance,
@@ -63,7 +57,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
if (priceEl) { if (priceEl) {
priceEl.innerHTML = ethPrice priceEl.innerHTML = ethPrice
? escapeHtml(formatUsd(ethPrice) + " USD/ETH") ? formatUsd(ethPrice) + " USD/ETH"
: "&nbsp;"; : "&nbsp;";
} }
@@ -74,13 +68,12 @@ function renderTotalValue() {
return; return;
} }
const ethBal = parseFloat(addr.balance || "0"); const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency(); const ethStr = ethBal.toFixed(4) + " ETH";
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : ""; const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
if (subEl) { if (subEl) {
subEl.innerHTML = subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
escapeHtml(formatAddressTotal(getAddressValue(addr))) || "&nbsp;";
} }
} }
@@ -131,10 +124,10 @@ function renderHomeTxList(ctx) {
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
const titleStr = title ? escapeHtml(title) : ""; const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; 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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr); html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`; html += `</div>`;
@@ -189,11 +182,7 @@ async function loadHomeTxs(ctx) {
try { try {
const fetches = allAddresses.map((addr) => const fetches = allAddresses.map((addr) =>
fetchRecentTransactions( fetchRecentTransactions(addr, state.blockscoutUrl),
addr,
state.blockscoutUrl,
currentNetwork().chainId,
),
); );
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
@@ -241,7 +230,7 @@ function walletListHtml() {
state.wallets.forEach((wallet, wi) => { state.wallets.forEach((wallet, wi) => {
const defect = walletDefect(wallet); const defect = walletDefect(wallet);
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`; html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
// No "+" on a defective wallet: deriving another address from that // No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce // xpub would only add one more address the key does not produce
@@ -255,12 +244,12 @@ function walletListHtml() {
wallet.addresses.forEach((addr, ai) => { wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`; html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
const isActive = state.activeAddress === addr.address; const isActive = state.activeAddress === addr.address;
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg p-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" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
// Only where a wallet can spare the address: a wallet holding a // Only where a wallet can spare the address: a wallet holding a
// single address has no remove control, because its last address // single address has no remove control, because its last address
// is never removable. // is never removable.
const removeBtn = canRemoveAddress(wallet) const removeBtn = canRemoveAddress(wallet)
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1 p-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" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
: ""; : "";
const dot = addressDotHtml(addr.address); const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
@@ -281,7 +270,7 @@ function walletListHtml() {
} }
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`; html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
+4 -119
View File
@@ -5,8 +5,6 @@ const {
showFlash, showFlash,
addressTitle, addressTitle,
displaySymbol, displaySymbol,
escapeHtml,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -22,25 +20,10 @@ const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { const { getAddress } = require("ethers");
feeReserveWei,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const { getAddress, parseEther } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Whether the amount field holds what Max filled in. The confirmation screen
// re-derives a max ETH amount from its own fee estimate; typing in the field
// makes it an ordinary amount again.
let amountIsMax = false;
// Counts the times the Send screen has opened, so a Max fee estimate started
// before it was last opened fills nothing in.
let sendScreenOpenings = 0;
/** /**
* Validate a destination address string. * Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }. * Returns { valid: true } or { valid: false, error: "..." }.
@@ -141,7 +124,7 @@ function updateToValidation() {
function renderSendTokenSelect(addr) { function renderSendTokenSelect(addr) {
const sel = $("send-token"); const sel = $("send-token");
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`; sel.innerHTML = '<option value="ETH">ETH</option>';
const fraudSet = new Set( const fraudSet = new Set(
(state.fraudContracts || []).map((a) => a.toLowerCase()), (state.fraudContracts || []).map((a) => a.toLowerCase()),
); );
@@ -221,8 +204,7 @@ function updateSendBalance() {
$("send-balance").textContent = $("send-balance").textContent =
"Current balance: " + "Current balance: " +
truncateAmountNeverZero(addr.balance || "0") + truncateAmountNeverZero(addr.balance || "0") +
" " + " ETH";
nativeCurrency();
} else { } else {
const symbol = resolveSymbol( const symbol = resolveSymbol(
token, token,
@@ -244,102 +226,9 @@ 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) {
log.errorf(
"max amount fee estimate failed:",
e.shortMessage || e.message,
);
}
// While the estimate was in flight the user left the screen (and perhaps
// opened it again), typed an amount, or changed the address, the holding
// or the recipient: what they did wins.
if (
state.currentView !== "send" ||
sendScreenOpenings !== opening ||
currentAddress()?.address !== addr.address ||
(state.selectedToken || $("send-token").value) !== token ||
$("send-to").value.trim() !== to ||
$("send-amount").value !== typed
) {
return;
}
if (feeWei === null) {
showFlash("The network fee could not be estimated.");
return;
}
const amount = maxEthAmount(addr.balance, feeWei);
if (amount === null) {
showFlash("Your balance does not cover the network fee.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("send-token").addEventListener("change", () => { $("send-token").addEventListener("change", updateSendBalance);
// A filled-in maximum is the maximum of the holding it was filled in
// for.
if (amountIsMax) {
$("send-amount").value = "";
amountIsMax = false;
}
updateSendBalance();
});
$("btn-send-max").addEventListener("click", fillMaxAmount);
$("send-amount").addEventListener("input", () => {
amountIsMax = false;
});
// Initial state: disable review button until address is entered // Initial state: disable review button until address is entered
$("btn-send-review").disabled = true; $("btn-send-review").disabled = true;
@@ -416,7 +305,6 @@ function init(_ctx) {
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance, tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals, tokenDecimals: tokenDecimals,
max: amountIsMax,
}); });
}); });
@@ -427,10 +315,7 @@ function init(_ctx) {
}); });
} }
// Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() { function resetSendValidation() {
sendScreenOpenings++;
amountIsMax = false;
const errorEl = $("send-to-error"); const errorEl = $("send-to-error");
const btn = $("btn-send-review"); const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = ""; if (errorEl) errorEl.textContent = "";
+7 -2
View File
@@ -213,7 +213,12 @@ function show() {
versionClickCount = 0; versionClickCount = 0;
// Show debug well if debug mode is already enabled // Show debug well if debug mode is already enabled
$("settings-debug-well").classList.toggle("hidden", !state.debugMode); const debugWell = $("settings-debug-well");
if (state.debugMode) {
debugWell.style.display = "";
} else {
debugWell.style.display = "none";
}
$("settings-debug-mode").checked = state.debugMode; $("settings-debug-mode").checked = state.debugMode;
showView("settings"); showView("settings");
@@ -429,7 +434,7 @@ function init(ctx) {
if (versionClickCount >= 10) { if (versionClickCount >= 10) {
versionClickCount = 0; versionClickCount = 0;
clearTimeout(versionClickTimer); clearTimeout(versionClickTimer);
$("settings-debug-well").classList.remove("hidden"); $("settings-debug-well").style.display = "";
} }
}); });
+2 -2
View File
@@ -12,7 +12,7 @@ function isTracked(address) {
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower); return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
} }
function nameAndSymbol(t) { function tokenLabel(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol; return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
} }
@@ -60,7 +60,7 @@ function renderDropdown() {
let html = '<option value="">-- select --</option>'; let html = '<option value="">-- select --</option>';
for (const t of tokens) { for (const t of tokens) {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : ""); const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${escapeHtml(t.address)}"` + `<option value="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${escapeHtml(t.symbol)}"` +
+2 -9
View File
@@ -3,10 +3,8 @@
// Everything else in the popup assumes a loaded profile: showView() reads and // Everything else in the popup assumes a loaded profile: showView() reads and
// writes the state singleton, every view renders from it, and the Settings // writes the state singleton, every view renders from it, and the Settings
// gear leads to a screen that does both. None of that is available here — by // gear leads to a screen that does both. None of that is available here — by
// the time this runs, the stored record has been REFUSED, deliberately: at // the time this runs, loadState() has REFUSED, deliberately, and reading the
// open loadState() refused it and reading the singleton throws // singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
// //
// So this module talks to the DOM directly and touches no state at all. It is // So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it // the one screen that must work when nothing else can, which is also why it
@@ -172,11 +170,6 @@ function wire() {
* refused, or its sentence. * refused, or its sentence.
*/ */
function show(problem) { function show(problem) {
// Already up: a later save that trips over the same record, such as a
// refresh that was in flight when the screen went up, must not clear what
// the user has exported or typed here.
if (!$("view-state-recovery").classList.contains("hidden")) return;
const sentence = const sentence =
(problem && (problem.problem || problem.message)) || String(problem); (problem && (problem.problem || problem.message)) || String(problem);
+13 -16
View File
@@ -13,7 +13,6 @@ const {
isoDate, isoDate,
timeAgo, timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
@@ -22,8 +21,8 @@ const {
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
@@ -42,10 +41,13 @@ function getTransactionType(tx) {
return "Token Approval"; return "Token Approval";
return "Contract Call"; return "Contract Call";
} }
// By the token contract, not the symbol: a token chooses its own symbol if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
// and can report the native token's, but only a token transfer has one. return "Native ETH Transfer";
if (tx.contractAddress) return "ERC-20 Token Transfer"; }
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
@@ -82,11 +84,6 @@ function show(tx) {
isContractCall: tx.isContractCall || false, isContractCall: tx.isContractCall || false,
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// a site can switch the active network before a later popup
// shows this screen again.
chainId: tx.chainId,
}, },
}; };
render(); render();
@@ -182,7 +179,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to, tx.chainId); loadFullTxDetails(tx.hash, tx.to);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -200,7 +197,7 @@ function showDetailField(sectionId, contentId, value) {
section.classList.remove("hidden"); section.classList.remove("hidden");
} }
function populateOnChainDetails(txData, chainId) { function populateOnChainDetails(txData) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number)); const blockLink = explorerUrl("block", String(txData.block_number));
@@ -230,7 +227,7 @@ function populateOnChainDetails(txData, chainId) {
showDetailField( showDetailField(
"tx-detail-fee-section", "tx-detail-fee-section",
"tx-detail-fee", "tx-detail-fee",
feeEth + " " + nativeCurrencyByChainId(chainId), feeEth + " ETH",
); );
} }
@@ -290,7 +287,7 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
async function loadFullTxDetails(txHash, toAddress, chainId) { async function loadFullTxDetails(txHash, toAddress) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -307,7 +304,7 @@ async function loadFullTxDetails(txHash, toAddress, chainId) {
const txData = await resp.json(); const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null; const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return; if (!inputData || inputData === "0x") return;
+18 -13
View File
@@ -13,10 +13,9 @@ const {
explorerUrl, explorerUrl,
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -87,13 +86,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait(); endWait();
const id = waitId; const id = waitId;
// A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: a site can switch the active network
// while this screen is open or before a later popup resumes it.
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId) ? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
@@ -198,10 +193,9 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of // an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup // restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token, // broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are // txInfo.tokenSymbol are deliberately unchecked: they are compared and
// compared and coalesced rather than dereferenced, and tokenSymbol is null for // coalesced rather than dereferenced, and tokenSymbol is null for ETH.
// ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -229,7 +223,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId) ? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
@@ -243,6 +237,17 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;
@@ -315,7 +320,7 @@ function showError(txInfo, txHash, message) {
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId) ? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
+5 -5
View File
@@ -23,11 +23,11 @@
// disputed is refused rather than guessed at. // disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately // Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result. toDecimals() is that check, stopping at the 80 // reporting that call's result. toDecimals() is that check, shared with the
// places formatUnits() accepts, and shared with the send path rather than // send path rather than copied: the bundled list stores numbers, the
// copied: the bundled list stores numbers, the explorer's copy arrives as a // explorer's copy arrives as a string, and a token the user added by hand
// string, and a token the user added by hand carries whatever lookupTokenInfo() // carries whatever lookupTokenInfo() got back, so the accepted types are
// got back, so the accepted types are enumerated rather than coerced. // enumerated rather than coerced.
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
+2 -13
View File
@@ -54,11 +54,9 @@ const {
formatEther, formatEther,
getAddress, getAddress,
getBytes, getBytes,
toQuantity,
verifyMessage, verifyMessage,
verifyTypedData, verifyTypedData,
} = require("ethers"); } = require("ethers");
const { nativeCurrencyByChainId } = require("./networks");
// The only transaction types this wallet signs: legacy, EIP-2930 and // The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can // EIP-1559. populateTransaction() produces nothing else, so nothing else can
@@ -409,21 +407,12 @@ function assertWithinCeilings(tx) {
price = normalizeQuantity(tx.gasPrice, "gas price"); price = normalizeQuantity(tx.gasPrice, "gas price");
} }
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) { if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
// The fee is paid in the native currency of the network the
// transaction is for. Every caller's transaction names it.
const nativeCurrency = nativeCurrencyByChainId(
present(tx.chainId) ? toQuantity(tx.chainId) : null,
);
throw refuse( throw refuse(
"This transaction would allow a network fee of up to " + "This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) + formatEther(gasLimit * price) +
" " + " ETH, which is more than the " +
nativeCurrency +
", which is more than the " +
formatEther(MAX_TOTAL_FEE) + formatEther(MAX_TOTAL_FEE) +
" " + " ETH this wallet will sign for.",
nativeCurrency +
" this wallet will sign for.",
); );
} }
} }
+8 -8
View File
@@ -147,20 +147,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// null is a holding of an amount that cannot be stated, which is // null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one. // not the same as a holding of zero, and must never render as one.
const bal = scale === null ? null : formatTokenBalance(raw, scale); const bal = scale === null ? null : formatTokenBalance(raw, scale);
// null means the explorer reported no readable count, which is // null means the explorer reported no count, which is not the
// not the same as a count of zero. This gate is not the // same as a count of zero. This gate is not the low-holder
// low-holder display filter: it has no user-facing off switch and // display filter: it has no user-facing off switch and governs
// governs the whole balance list, so it stays strict and admits a // the whole balance list, so it stays strict and admits a token
// token only on a reported count — an unreported one is no // only on a reported count — an unreported one is no evidence.
// evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A // A legitimate token still reaches the list through the known
// legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried // token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat // through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero. // an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count); const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr); const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD; const hasEnoughHolders =
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see // Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue; if (!isKnown && !isTracked && !hasEnoughHolders) continue;
+2
View File
@@ -33,6 +33,7 @@ const DEBUG_MNEMONIC = DEBUG
: null; : null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com"; const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -68,6 +69,7 @@ module.exports = {
BUILD_DEBUG_MARKER, BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL, DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
+6 -15
View File
@@ -9,22 +9,13 @@
const LOW_HOLDER_THRESHOLD = 1000; const LOW_HOLDER_THRESHOLD = 1000;
// Parse an explorer-supplied holders_count into a number, or null when it is // Parse an explorer-supplied holders_count into a number, or null when the
// not one. Only a whole number of zero or more, or a string made of nothing // explorer did not report one. Anything unparseable is unknown too: a count
// but the digits 0-9, is a count. Anything else is null, never read in part: // we cannot read is not a count of zero.
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
// null too: a number cannot hold it exactly, so it would come back rounded,
// or as Infinity.
function parseHoldersCount(raw) { function parseHoldersCount(raw) {
if (typeof raw === "number") { if (raw === null || raw === undefined || raw === "") return null;
return Number.isSafeInteger(raw) && raw >= 0 ? raw : null; const n = parseInt(raw, 10);
} return Number.isFinite(n) ? n : null;
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
const count = Number(raw);
return Number.isSafeInteger(count) ? count : null;
}
return null;
} }
// True only for a token the explorer reported as having fewer holders than // True only for a token the explorer reported as having fewer holders than
+5 -8
View File
@@ -76,13 +76,10 @@ function networkByChainId(chainId) {
return null; return null;
} }
// The native currency of the network with this chain id. A transaction's // Build a block explorer link for the given path type and value.
// value and fee are labelled with the one of the chain the transaction is on, // type: "address" | "tx" | "token" | "block"
// which need not be the active network. `ETH` when the chain id is missing or function explorerLink(network, type, value) {
// no network here has it. return `${network.explorerUrl}/${type}/${value}`;
function nativeCurrencyByChainId(chainId) {
const network = networkByChainId(chainId);
return network ? network.nativeCurrency : "ETH";
} }
module.exports = { module.exports = {
@@ -92,5 +89,5 @@ module.exports = {
isKnownNetworkId, isKnownNetworkId,
networkById, networkById,
networkByChainId, networkByChainId,
nativeCurrencyByChainId, explorerLink,
}; };
+23
View File
@@ -104,6 +104,27 @@ function getAddressValue(addr) {
return { usd, partial }; return { usd, partial };
} }
// The same pair for a whole wallet, and for every wallet at once. One
// unpriced holding anywhere makes the sum a floor, so partial carries up.
function getWalletValue(wallet) {
return sumValues(wallet.addresses.map(getAddressValue));
}
function getTotalValue(wallets) {
return sumValues(wallets.map(getWalletValue));
}
function sumValues(values) {
let usd = null;
let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
}
return { usd, partial };
}
// The one rendering of an address total, so no screen says it differently. // The one rendering of an address total, so no screen says it differently.
// //
// A partial total is shown and named as partial: the figure is the ETH and // A partial total is shown and named as partial: the figure is the ETH and
@@ -128,4 +149,6 @@ module.exports = {
formatUsd, formatUsd,
formatAddressTotal, formatAddressTotal,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
}; };
+5 -12
View File
@@ -122,9 +122,9 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as they stood at the end of this page's last
// finished, or when its last successful saveState() began. saveState() diffs // loadState() or saveState(). saveState() diffs the live state against this
// the live state against this to find only the fields THIS page changed since. // to find only the fields THIS page actually changed.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -464,10 +464,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. // before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() { async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it const current = snapshotPersisted();
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -524,11 +521,7 @@ async function saveStateOnce() {
// exactly as it stood; see the note above. // exactly as it stood; see the note above.
rawState.hasWallet = rawState.wallets.length > 0; rawState.hasWallet = rawState.wallets.length > 0;
// What this save compared and wrote, not the page's state now: a change baseline = structuredClone(snapshotPersisted());
// made during the save must still differ from the baseline, or the save
// queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current;
} }
// showView() calls saveState() on every navigation without awaiting it, so // showView() calls saveState() on every navigation without awaiting it, so
+2 -7
View File
@@ -20,10 +20,6 @@
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever // (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking // truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it. // at is not the whole name — worth knowing before they send to it.
//
// Characters are counted as code points, not UTF-16 units, so an emoji is
// one character and the cut never falls between the two halves of one: a
// half on its own renders as U+FFFD.
const MAX_SYMBOL_LENGTH = 12; const MAX_SYMBOL_LENGTH = 12;
@@ -36,9 +32,8 @@ const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) { function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol); const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL; if (s.length === 0) return UNKNOWN_SYMBOL;
const chars = Array.from(s); if (s.length <= MAX_SYMBOL_LENGTH) return s;
if (chars.length <= MAX_SYMBOL_LENGTH) return s; return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
} }
module.exports = { module.exports = {
+2 -2
View File
@@ -11,8 +11,8 @@
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses // KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// that may bear it, or to null. Null means the symbol belongs to the native // that may bear it, or to null. Null means the symbol belongs to the native
// asset, which has no contract at all, so no contract may bear it and every // asset, which has no contract at all, so no contract may bear it and every
// one that does is a spoof. "ETH" is one such entry, and every network's // one that does is a spoof. "ETH" is the only such entry today; the rule is
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network. // written so that a second one needs no change here or at any call site.
// //
// The value is a set because a ticker is not unique: seven symbols in the // The value is a set because a ticker is not unique: seven symbols in the
// bundled list belong to two real contracts each, and answering with one of // bundled list belong to two real contracts each, and answering with one of
+1 -7
View File
@@ -6,7 +6,6 @@
// 511 tokens. // 511 tokens.
const { debugFetch } = require("./log"); const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick"; const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
@@ -3611,9 +3610,7 @@ for (const t of TOKENS) {
// Build a map of symbol (uppercased) -> the set of contract addresses // Build a map of symbol (uppercased) -> the set of contract addresses
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection. // (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may // "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol. So does every network's `nativeCurrency` in networks.js // bear its symbol.
// (`SepoliaETH`), on every network, since that is the label the wallet shows
// its native asset under on that network.
// //
// The value is a set and not a single address because tickers are not unique // The value is a set and not a single address because tickers are not unique
// and the list above proves it: seven of these 512 tokens share a symbol with // and the list above proves it: seven of these 512 tokens share a symbol with
@@ -3627,9 +3624,6 @@ for (const t of TOKENS) {
// loosen the rule, because a contract outside the set is still a spoof. // loosen the rule, because a contract outside the set is still a spoof.
const KNOWN_SYMBOLS = new Map(); const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null); KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) {
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
}
for (const t of TOKENS) { for (const t of TOKENS) {
const upper = t.symbol.toUpperCase(); const upper = t.symbol.toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) { if (!KNOWN_SYMBOLS.has(upper)) {
+9 -23
View File
@@ -11,7 +11,6 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
// The uint8 test every scale in this wallet goes through. Shared, not copied: // The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the // a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one. // same thing here as it does on the screens that refuse to format one.
@@ -29,7 +28,7 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
function parseTx(tx, addrLower, chainId) { function parseTx(tx, addrLower) {
const from = tx.from?.hash || ""; const from = tx.from?.hash || "";
const to = tx.to?.hash || ""; const to = tx.to?.hash || "";
const rawWei = tx.value || "0"; const rawWei = tx.value || "0";
@@ -37,7 +36,7 @@ function parseTx(tx, addrLower, chainId) {
const method = tx.method || null; const method = tx.method || null;
// For contract calls, produce a meaningful label instead of "0.0000 ETH" // For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = nativeCurrencyByChainId(chainId); let symbol = "ETH";
let value = formatTxValue(formatEther(rawWei)); let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei); let exactValue = formatEther(rawWei);
let rawAmount = rawWei; let rawAmount = rawWei;
@@ -91,11 +90,10 @@ function parseTx(tx, addrLower, chainId) {
holders: null, holders: null,
isContractCall: toIsContract, isContractCall: toIsContract,
method: method, method: method,
chainId: chainId,
}; };
} }
function parseTokenTransfer(tt, addrLower, chainId) { function parseTokenTransfer(tt, addrLower) {
const from = tt.from?.hash || ""; const from = tt.from?.hash || "";
const to = tt.to?.hash || ""; const to = tt.to?.hash || "";
// The explorer's own answer, or null. Never a default: a transfer of // The explorer's own answer, or null. Never a default: a transfer of
@@ -137,12 +135,10 @@ function parseTokenTransfer(tt, addrLower, chainId) {
contractAddress: normalizeAddress( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "", tt.token?.address_hash || tt.token?.address || "",
), ),
// null when the explorer reported no readable count: unknown, not // null when the explorer reported no count: unknown, not zero. The
// zero. The low-holder filter declines to judge a null, so a // low-holder filter declines to judge a null, so a legitimate token
// legitimate token is not hidden because a field went missing // is not hidden because a field went missing upstream.
// upstream.
holders: parseHoldersCount(tt.token?.holders_count), holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId,
}; };
} }
@@ -225,15 +221,7 @@ function mergeTransactions(txs, tokenTransfers) {
return merged; return merged;
} }
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
async function fetchRecentTransactions(
address,
blockscoutUrl,
chainId,
count = 25,
) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address); const addrLower = normalizeAddress(address);
@@ -266,10 +254,8 @@ async function fetchRecentTransactions(
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions( const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)), (txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => (ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
parseTokenTransfer(tt, addrLower, chainId),
),
); );
const result = txs.slice(0, count); const result = txs.slice(0, count);
+4 -6
View File
@@ -27,11 +27,9 @@
const { parseUnits } = require("ethers"); const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and // Solidity's decimals() returns a uint8, so anything outside that range is not
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber // an answer this wallet can use.
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor const MAX_DECIMALS = 255;
// encoded, so it is not an answer this wallet can use, the same as no answer.
const MAX_DECIMALS = 80;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE = const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" + "The transfer was not sent, because the number of decimal places this" +
@@ -57,7 +55,7 @@ function mismatchMessage(displayed, onChain) {
// A decimals value from any source as a number, or null if it is not one. // A decimals value from any source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives // decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything // as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, above MAX_DECIMALS, or of any other type at all is not. // fractional, negative, out of uint8 range, or of any other type at all is not.
// //
// The types are enumerated rather than coerced because Number() is far too // The types are enumerated rather than coerced because Number() is far too
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would // willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
+7 -30
View File
@@ -1,4 +1,4 @@
// Balance arithmetic for the Send and transaction confirmation screens. // Balance arithmetic for the transaction confirmation screen.
// //
// Pure: no DOM, no network, no state. Everything is exact integer math on // Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly // 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -10,7 +10,7 @@
// the token balance arrive as human decimal strings, so comparing them at a // the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact. // common scale is exact.
const { parseUnits, formatEther } = require("ethers"); const { parseUnits } = require("ethers");
const SCALE_DECIMALS = 18; const SCALE_DECIMALS = 18;
@@ -87,28 +87,6 @@ function toFixedPoint(value) {
} }
} }
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
// balances.js stores, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is
// unusable.
function maxEthAmount(ethBalance, feeWei) {
const balanceWei = toFixedPoint(ethBalance);
if (balanceWei === null) return null;
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
const amountWei = balanceWei - feeWei;
if (amountWei <= 0n) return null;
return formatEther(amountWei);
}
// The most of a token a send can carry: its balance cut down, never rounded
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
// declare more than 18 decimals, and its balance is stored with all of them.
function maxTokenAmount(tokenBalance) {
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
}
// Validate a pending transfer against the balances that must cover it. // Validate a pending transfer against the balances that must cover it.
// //
// isErc20 — token transfer rather than a native ETH transfer // isErc20 — token transfer rather than a native ETH transfer
@@ -161,12 +139,13 @@ function validateTransfer({
const feeFp = known ? feeWei : null; const feeFp = known ? feeWei : null;
if (isErc20) { if (isErc20) {
// Only the first 18 places of the balance are read: an amount with // A token can declare more than 18 decimals, and its balance is
// more was refused above, so the places after them cannot decide // stored with all of them. Only the first 18 places (SCALE_DECIMALS)
// whether the amount fits. // are read: an amount with more was refused above, so the places
// after them cannot decide whether the amount fits.
const tokenText = const tokenText =
typeof tokenBalance === "string" typeof tokenBalance === "string"
? maxTokenAmount(tokenBalance) ? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
: tokenBalance; : tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n; const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN); if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -195,8 +174,6 @@ module.exports = {
SCALE_DECIMALS, SCALE_DECIMALS,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
}; };
+15 -47
View File
@@ -84,31 +84,17 @@ function present(value) {
// //
// `amountOutMinimum` gets no such mapping: V4Router compares it directly // `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so // (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor // a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput` // the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero — // special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2 // so a zero `amountIn` there is a literal zero and is displayed as one.
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
const OPEN_DELTA = Symbol("v4-open-delta"); const OPEN_DELTA = Symbol("v4-open-delta");
// The Universal Router's V2 exact-in spells "the pair already holds the input // The two amount lines that state a fact instead of a quantity. Same register
// tokens" as an amount of zero: universal-router // as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// `contracts/libraries/Constants.sol` declares // as a number — and deliberately not a third phrasing of "not named": these
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases // say different things.
// when a v2 pair has already received input tokens"), and
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
// equals it. The swap then spends whatever an earlier step sent to the pair.
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say
// different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const NO_MINIMUM = "None (no minimum guaranteed)"; const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -199,7 +185,6 @@ function decodeBalanceCheck(input) {
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes. // Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
function decodeV2SwapExactIn(input) { function decodeV2SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -207,7 +192,7 @@ function decodeV2SwapExactIn(input) {
input, input,
); );
return { return {
amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountIn: d[1],
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
@@ -517,13 +502,7 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x0e) { if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at if (b) setOutput(b.token, b.minBalance);
// least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
}
} }
if (cmdId === 0x00) { if (cmdId === 0x00) {
@@ -644,20 +623,14 @@ function decode(data, toAddress, sources) {
} }
if (present(inputAmount)) { if (present(inputAmount)) {
// Three amounts need no scale to describe and are named rather // Two amounts need no scale to describe and are named rather than
// than formatted: V4's open delta and V2's already-paid zero, // formatted: V4's open delta, which is not a quantity at all (see
// neither of which is a quantity at all (see OPEN_DELTA and // OPEN_DELTA), and an unbounded permit. The open-delta test comes
// ALREADY_PAID), and an unbounded permit. Those two tests come // first — the sentinel is not a bigint and cannot be compared with
// first — the sentinels are not bigints and cannot be compared // one.
// with one.
let amount; let amount;
if (inputAmount === OPEN_DELTA) { if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount === ALREADY_PAID) {
amount = {
raw: ALREADY_PAID_AMOUNT,
display: ALREADY_PAID_AMOUNT,
};
} else if (inputAmount >= MAX_UINT160) { } else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" }; amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) { } else if (hasV2ExactOut) {
@@ -724,15 +697,10 @@ function decode(data, toAddress, sources) {
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") }); details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
// later deadline, such as the uint256 maximum, makes an invalid date,
// and toISOString() throws on one, so that deadline is said in words.
const deadlineDate = new Date(Number(deadline) * 1000); const deadlineDate = new Date(Number(deadline) * 1000);
details.push({ details.push({
label: "Deadline", label: "Deadline",
value: isNaN(deadlineDate.getTime()) value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
? "After 275760-09-13 00:00:00 (no deadline in practice)"
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
}); });
return { return {
+5 -11
View File
@@ -13,17 +13,11 @@ const NON_MASTER_XPRV = "non-master-xprv";
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below // An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
// the key that was imported: the current import path derives the absolute // the key that was imported: the current import path derives the absolute
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7) // m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
// derived the same four levels as a relative path beneath whatever depth it // four levels as a relative path beneath whatever depth it was given. A master
// was given. A master import therefore stores a depth-4 xpub and a depth-d // import therefore stores a depth-4 xpub and a depth-d import stores depth
// import stores depth d + 4, which makes the stored xpub an exact read on the // d + 4, which makes the stored xpub an exact read on the imported key's
// imported key's depth — and it is readable without the password, unlike the // depth — and it is readable without the password, unlike the key itself.
// key itself.
//
// The first import path (7a7f9c5) does not fit: it stored the imported key's
// own xpub with no derivation, so a wallet it wrote is judged wrongly here (a
// master import as defective, a depth-4 import as sound). 57959b7 replaced it
// in the same push, and no tag contains it.
const BIP44_ETH_XPUB_DEPTH = 4; const BIP44_ETH_XPUB_DEPTH = 4;
const DEFECTS = { const DEFECTS = {
+13 -8
View File
@@ -22,6 +22,8 @@ const {
prices, prices,
clearPrices, clearPrices,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal, formatAddressTotal,
} = require("../src/shared/prices"); } = require("../src/shared/prices");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -134,6 +136,17 @@ describe("the value of an address, and whether it is the whole value", () => {
partial: false, partial: false,
}); });
}); });
test("one unpriced holding makes a wallet and the grand total partial", () => {
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
});
test("a wallet of fully priced addresses stays complete", () => {
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
});
}); });
describe("how that value is written on screen", () => { describe("how that value is written on screen", () => {
@@ -194,14 +207,6 @@ describe("the wallet list on Home", () => {
clearPrices(); clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;"); expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
}); });
// A total under a cent is written "< $0.01", and the "<" is escaped
// here as the removal warning escapes it.
test("a total under a cent is escaped, as on the removal warning", () => {
const tiny = { ...EMPTY, balance: "0.000001" };
expect(walletListTotal(tiny)).toBe("Total: &lt; $0.01");
expect(removalWarningTotal(tiny)).toBe("Total: &lt; $0.01");
});
}); });
describe("the balance warning on the address-removal confirmation", () => { describe("the balance warning on the address-removal confirmation", () => {
-50
View File
@@ -31,15 +31,11 @@ const iface = new Interface(ERC20_ABI);
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e"; const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals. // In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// In the bundled list, at 0 decimals.
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111"; const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue. // 5,000 units of a 6-decimal token, the amount from the issue.
const FIVE_THOUSAND_AT_SIX = 5000000000n; const FIVE_THOUSAND_AT_SIX = 5000000000n;
// 5,000 units of a 0-decimal token, which are 5,000 tokens.
const FIVE_THOUSAND_AT_ZERO = 5000n;
const MAX_UINT256 = (1n << 256n) - 1n; const MAX_UINT256 = (1n << 256n) - 1n;
function transferData(amount) { function transferData(amount) {
@@ -117,21 +113,6 @@ describe("resolveTokenDecimals", () => {
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6); expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
}); });
// Zero decimals is a real scale, not a missing one, so a source that
// answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
expect(resolveTokenDecimals(SLP, state)).toBe(0);
});
test("uses a tracked scale of zero", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
];
state.wallets = walletsHolding(NOVEL_TOKEN, 18);
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(0);
});
test("refuses a scale the explorer's own entries disagree about", () => { test("refuses a scale the explorer's own entries disagree about", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6); const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({ wallets[0].addresses.push({
@@ -203,22 +184,6 @@ describe("decodeCalldata amount", () => {
expect(line).not.toMatch(/0\.0000/); expect(line).not.toMatch(/0\.0000/);
}); });
// A token added by hand carries whatever its decimals() returned, and a
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
// call undecoded rather than refused
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("a token reporting more than 80 decimals shows base units", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 81 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000000000 base units (decimals unknown)");
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000000000 base units (decimals unknown)",
);
});
test("an unbounded allowance is still named, with or without a scale", () => { test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe( expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited", "Unlimited",
@@ -232,21 +197,6 @@ describe("decodeCalldata amount", () => {
); );
}); });
test("a bundled token with zero decimals shows the true quantity", () => {
expect(amountLine(transferData(FIVE_THOUSAND_AT_ZERO), SLP)).toBe(
"5000.0000 SLP",
);
});
test("a tracked token with zero decimals shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_ZERO), NOVEL_TOKEN),
).toBe("5000.0000 NOVEL");
});
test("the amount carried to the status screens is the same string", () => { test("the amount carried to the status screens is the same string", () => {
const decoded = decodeCalldata( const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX), transferData(FIVE_THOUSAND_AT_SIX),
-18
View File
@@ -599,24 +599,6 @@ describe("verifySignedTx field comparison", () => {
expect(e.message).toContain("1.0 ETH"); expect(e.message).toContain("1.0 ETH");
} }
}); });
// The fee is in the native currency of the network the transaction is
// for, whether its chain id is the hex string the background prepares
// or the number ethers parses from a signed transaction.
test.each([
["0x1", "ETH"],
[1n, "ETH"],
["0xaa36a7", "SepoliaETH"],
[11155111n, "SepoliaETH"],
])("the refusal on chain %p names %s", (chainId, nativeCurrency) => {
expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow(
"up to 3000.0 " +
nativeCurrency +
", which is more than the 1.0 " +
nativeCurrency +
" this wallet",
);
});
}); });
test("every field mismatch is a refusal, not a warning", async () => { test("every field mismatch is a refusal, not a warning", async () => {
+2 -110
View File
@@ -267,22 +267,9 @@ function loadBackground(options) {
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(opts.lastFocused || null), getLastFocused: (cb) => cb(null),
create: (options2, cb) => { create: (options2, cb) => {
// A copy, as the browser takes it at the call: the background created.push(options2);
// reuses the object when it asks a second time.
created.push({ ...options2 });
// A browser that refuses any position it is given, as Chrome
// does for one it judges too far off screen.
if (opts.refusePosition && options2.left !== undefined) {
global.chrome.runtime.lastError = {
message:
"Invalid value for bounds. Bounds must be at least 50% within visible screen space.",
};
cb(undefined);
global.chrome.runtime.lastError = null;
return;
}
// A browser that answers with no window at all. The approval // A browser that answers with no window at all. The approval
// then has no window it can ever be answered in. // then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length }); cb(opts.noWindow ? undefined : { id: created.length });
@@ -2248,27 +2235,6 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// The prompt is decided before the toolbar popup raised for it has
// loaded; that popup is torn down and openPopup() rejects only after.
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
const bg = loadBackground({ actionPopup: true });
const opening = deferred();
bg.openPopup.mockImplementation(() => opening.promise);
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
opening.reject(new Error("the toolbar popup closed before it loaded"));
await settle();
expect(bg.created).toHaveLength(0);
});
// The port carries a decision now, so it carries the sender check the // The port carries a decision now, so it carries the sender check the
// one-off message used to carry. A content script that guessed an // one-off message used to carry. A content script that guessed an
// approval id must not be able to connect the site it is running on. // approval id must not be able to connect the site it is running on.
@@ -2729,77 +2695,3 @@ describe("removing a site in Settings disconnects it", () => {
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] }); expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
}); });
}); });
// An approval window still open is often the last focused window, and headless
// Chrome reports one as 1280x720. Centred on that, the next approval window
// lands where the browser refuses to create it, and its request failed with no
// window at all (https://git.eeqj.de/sneak/AutistMask/issues/290).
describe("where an approval window opens", () => {
test("centred on the browser window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "normal",
left: 0,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "popup",
left: 440,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
});
test("placed by the browser when it refuses the centred position", async () => {
const bg = loadBackground({
refusePosition: true,
lastFocused: {
type: "normal",
left: 1500,
top: 900,
width: 400,
height: 300,
},
});
const sign = bg.requestSign();
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
// The request waits on the second window rather than failing:
// closing that window is refusing the prompt.
expect(sign.result()).toBeNull();
bg.closeWindow(2);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
});
-7
View File
@@ -66,11 +66,4 @@ describe("balanceLine", () => {
expect(html).toContain("<span>1.5000</span>"); expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"'); expect(html).toContain('data-token="0xabc"');
}); });
// formatUsd() writes a value under a cent as "< $0.01".
test("escapes the USD value along with the symbol", () => {
const html = balanceLine("USDC", 0.001, 1, null);
expect(html).toContain("&lt; $0.01");
expect(html).not.toContain("< $0.01");
});
}); });
+2 -2
View File
@@ -301,7 +301,7 @@ describe.each([
test("a contract creation's row says so, with no colour dot and no address line", async () => { test("a contract creation's row says so, with no colour dot and no address line", async () => {
const html = await rowsFor(historyTx("")); const html = await rowsFor(historyTx(""));
expect(html).toContain(SENTENCE); expect(html).toContain(SENTENCE);
expect(html).not.toContain("bg-[#"); expect(html).not.toContain("background:");
expect(html).not.toContain("am-address"); expect(html).not.toContain("am-address");
expect(html).not.toContain("undefined"); expect(html).not.toContain("undefined");
}); });
@@ -309,7 +309,7 @@ describe.each([
test("a transaction with a recipient shows its colour dot and address", async () => { test("a transaction with a recipient shows its colour dot and address", async () => {
const html = await rowsFor(historyTx(RECIPIENT)); const html = await rowsFor(historyTx(RECIPIENT));
expectAddressLine(html); expectAddressLine(html);
expect(html).toContain("bg-[#"); expect(html).toContain("background:#");
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`); expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
}); });
}); });
-69
View File
@@ -46,9 +46,6 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// U+200B, built from its code point so that it can be seen in this file.
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
// ------------------------------------------------------------ DOM stub // ------------------------------------------------------------ DOM stub
function makeElement(id) { function makeElement(id) {
@@ -345,72 +342,6 @@ describe("the typed confirmation", () => {
"secret-three", "secret-three",
]); ]);
}); });
// A name of only spaces compares as nothing, and so does an empty
// field. Typing nothing must still delete nothing.
test.each(["", " "])(
"typing %j deletes nothing when the name is only spaces",
async (typedValue) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = " ";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = typedValue;
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets).toHaveLength(3);
expect(await persistedWallets(storage)).toHaveLength(3);
},
);
// A name that shows nothing would leave nothing on screen to type
// back, so the screen names the wallet by its position instead, and
// that is what the user types.
test.each([
["spaces", " "],
["a zero-width space", ZERO_WIDTH_SPACE],
])(
"a name of only %s is shown and typed back as Wallet 2",
async (_label, storedName) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = storedName;
await openLostPassword(deleteWallet, 1);
expect(node("delete-wallet-lost-name").textContent).toBe(
"Wallet 2",
);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
},
);
// A zero-width space paints nothing, so "My", a zero-width space and
// "Wallet" reads as "MyWallet", and that is all the user can type. HTML
// does not collapse it the way it collapses spaces, so it has to be
// removed explicitly.
test("a zero-width space inside the name is not part of it", async () => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = "My" + ZERO_WIDTH_SPACE + "Wallet";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "MyWallet";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
});
}); });
describe("deleting without the password", () => { describe("deleting without the password", () => {
-136
View File
@@ -46,7 +46,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { isDeepStrictEqual } = require("util");
const { const {
Transaction, Transaction,
@@ -63,10 +62,6 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp"); const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network"); const { STUB_COUNTERPARTY } = require("../network");
const {
STATE_SCHEMA_VERSION,
stateProblem,
} = require("../../../src/shared/stateSchema");
const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html"; const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
@@ -113,137 +108,6 @@ step("popup loads and reaches the welcome view", async (env) => {
assert(title === "AutistMask", "unexpected popup title: " + title); assert(title === "AutistMask", "unexpected popup title: " + title);
}); });
// The same check as the Chrome suite's (#418), so both browsers are held to
// the same font.
step("the popup is drawn in the monospace font it declares", async (env) => {
const font = await env.driver.execute(
"return getComputedStyle(document.body).fontFamily;",
);
// --font-mono in src/popup/styles/main.css, as the browser writes it out.
assert(
font ===
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
"the popup is drawn in " + font + ", not in --font-mono",
);
});
// The recovery screen (#361): the Chrome suite's four cases, run before any
// wallet exists for the same reason. With no wallet nothing saves on a timer,
// so no save can write a good record over the unreadable one. The last of them
// erases it, which leaves the popup on Welcome for wallet creation.
// A profile a newer build wrote: a wallet with its encrypted secret, under a
// schema version this build refuses to read.
const UNREADABLE_RECORD = {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-written-by-a-newer-build",
encryptedSecret: "ciphertext-written-by-a-newer-build",
nextIndex: 1,
addresses: [{ address: STUB_COUNTERPARTY }],
},
],
};
// The whole stored record, read on the popup page.
function storedRecord(d) {
return d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`,
);
}
step(
"an unreadable stored record opens the popup on the recovery screen",
async (env) => {
const d = env.driver;
// The popup the first step opened saves once, as it shows Welcome.
// Stored before that save lands, the record would be written over.
const deadline = Date.now() + 15000;
for (;;) {
const stored = await storedRecord(d);
if (stored && stored.currentView === "welcome") break;
assert(
Date.now() < deadline,
"the Welcome screen's save never landed: " +
JSON.stringify(stored),
);
await sleep(100);
}
await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`,
[UNREADABLE_RECORD],
);
await d.navigate(POPUP_URL);
await d.waitVisible("#view-state-recovery");
const problem = await d.text("#state-recovery-problem");
assert(
problem === stateProblem(UNREADABLE_RECORD),
"the recovery screen names the problem as " +
JSON.stringify(problem),
);
},
);
step("Export Saved Data shows the stored record verbatim", async (env) => {
const d = env.driver;
await d.click("#btn-state-recovery-export");
await d.waitVisible("#state-recovery-blob");
const exported = await d.value("#state-recovery-blob");
assert(exported !== "", "Export Saved Data left the text box empty");
assert(
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
"the text box does not hold the stored record: " + exported,
);
});
step("a near-miss confirmation phrase erases nothing", async (env) => {
const d = env.driver;
await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
await d.click("#btn-state-recovery-reset");
await d.waitFor(
"the refusal on the error line",
`return document.getElementById("state-recovery-flash").textContent ===
"Type ERASE MY WALLET to confirm. Nothing was erased.";`,
);
const stored = await storedRecord(d);
assert(
isDeepStrictEqual(stored, UNREADABLE_RECORD),
"the stored record changed: " + JSON.stringify(stored),
);
});
step(
"the exact confirmation phrase erases the record and reloads into Welcome",
async (env) => {
const d = env.driver;
try {
await d.fill("#state-recovery-reset-input", "ERASE MY WALLET");
await d.click("#btn-state-recovery-reset");
// Welcome is the proof of the erase: the record still stored
// would put the recovery screen up again, and its wallet would
// open Home.
await d.waitVisible("#view-welcome");
} finally {
// Whatever failed in these four steps, wallet creation starts
// from Welcome. The record left stored would fail every step
// after this.
if (!(await d.isVisible("#view-welcome"))) {
await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.remove("autistmask").then(() => done());`,
);
await d.navigate(POPUP_URL);
}
}
},
);
step("wallet creation through the UI reaches the main view", async (env) => { step("wallet creation through the UI reaches the main view", async (env) => {
const d = env.driver; const d = env.driver;
await d.click("#btn-welcome-add"); await d.click("#btn-welcome-add");
+11 -37
View File
@@ -22,7 +22,7 @@
"use strict"; "use strict";
const { AbiCoder, Transaction } = require("ethers"); const { Transaction } = require("ethers");
// Fictional ERC-20 used to seed the transaction-detail test. The symbol // Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or // must not collide with any entry in src/shared/tokenList.js, or
@@ -244,39 +244,26 @@ function latestBlock() {
}; };
} }
// keccak("decimals()")[0:4], and the same for symbol() and name(). // keccak("decimals()")[0:4].
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
// Every eth_call still answers with a zero word except decimals(), symbol() // Every eth_call still answers with a zero word except decimals() on the
// and name() on the stub token. The wallet reads decimals() back at signing // stub token, which the wallet reads back at signing time to compare with
// time to compare with the scale the confirmation screen rendered (issue // the scale the confirmation screen rendered (issue #305).
// #305). Adding the token by its contract address reads all three (issue
// #295); symbol() and name() answer what the explorer reports for it.
// //
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
// Blockscout, which is exactly the disagreement the wallet must refuse to // Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options // sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has // object the route was registered with — after the confirmation screen has
// been built — without re-registering anything. Only null or undefined means // been built — without re-registering anything.
// no override: 0 is a token with no decimal places, and is answered as one.
function ethCallResult(req, opts) { function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (to !== STUB_TOKEN.address) return ZERO_WORD; if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
if (data.startsWith(SELECTOR_DECIMALS)) { return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
}
const abi = AbiCoder.defaultAbiCoder();
if (data.startsWith(SELECTOR_SYMBOL)) {
return abi.encode(["string"], [tokenObject(opts).symbol]);
}
if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]);
} }
return ZERO_WORD; return ZERO_WORD;
} }
@@ -460,16 +447,6 @@ function rpcReply(req, opts, report) {
return Object.assign(envelope, { result: ethCallResult(req, opts) }); return Object.assign(envelope, { result: ethCallResult(req, opts) });
} }
if (req.method === "eth_getTransactionReceipt") { if (req.method === "eth_getTransactionReceipt") {
// A lookup that fails, which the wait screen counts differently from
// one that answers "not mined yet" (README.md, WaitTx).
if (opts.failReceiptLookup) {
return Object.assign(envelope, {
error: {
code: -32000,
message: "e2e fixture: receipt lookup failed",
},
});
}
const hash = Array.isArray(req.params) ? req.params[0] : null; const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, { return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null, result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
@@ -620,17 +597,14 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale * @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* decimals() answers for the stub token, in place of the value Blockscout * the stub token, in place of the value Blockscout reports for it. This is
* reports for it; null for none, while 0 is a scale like any other. This * the token that lies about its scale; read at request time.
* is the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as * @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose * the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time. * symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a * @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves. * confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
+403 -1078
View File
File diff suppressed because it is too large Load Diff
-33
View File
@@ -57,39 +57,6 @@ describe("parseHoldersCount", () => {
expect(parseHoldersCount("many")).toBeNull(); expect(parseHoldersCount("many")).toBeNull();
expect(parseHoldersCount(NaN)).toBeNull(); expect(parseHoldersCount(NaN)).toBeNull();
}); });
// Each of these starts with a digit, so reading only the leading digits
// would turn it into a small reported count, and a small count is
// exactly what hides a token as spam (issue #251).
test.each(["1,000", "0x10", "1e3", "12 holders"])(
"%p is not read in part: it is unknown",
(raw) => {
expect(parseHoldersCount(raw)).toBeNull();
},
);
test("a negative count is unknown", () => {
expect(parseHoldersCount("-5")).toBeNull();
expect(parseHoldersCount(-5)).toBeNull();
});
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
// string of digits would come back rounded, and a long enough one as
// Infinity, which would pass every holder-count floor.
test("a count too large for a number to hold exactly is unknown", () => {
expect(parseHoldersCount("9007199254740993")).toBeNull();
expect(parseHoldersCount("9".repeat(400))).toBeNull();
expect(parseHoldersCount(2 ** 53)).toBeNull();
});
test("the largest count a number holds exactly still parses", () => {
expect(parseHoldersCount("9007199254740991")).toBe(
Number.MAX_SAFE_INTEGER,
);
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
Number.MAX_SAFE_INTEGER,
);
});
}); });
describe("isLowHolderCount", () => { describe("isLowHolderCount", () => {
-11
View File
@@ -91,17 +91,6 @@ describe("displaySymbol", () => {
expect(displaySymbol(exact)).toBe(exact); expect(displaySymbol(exact)).toBe(exact);
}); });
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
// Cutting between them leaves half of one, which renders as U+FFFD.
test("counts an emoji as one character and never cuts one in half", () => {
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH),
);
expect(displaySymbol("🚀".repeat(20))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
);
});
test("substitutes a placeholder for an absent symbol", () => { test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL); expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL); expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
+8 -7
View File
@@ -21,14 +21,15 @@
// escaping in src/shared/html.js is the primary fix; default-src is what // escaping in src/shared/html.js is the primary fix; default-src is what
// stops the next escape that slips from reaching the network. // stops the next escape that slips from reaching the network.
// //
// And for #328: style-src is 'self' alone, so the browser refuses every // Every directive below is pinned exactly, because each of the four
// style="..." attribute in the popup's markup, including one an escape lets
// through. The popup styles with classes; script setting element.style is
// not affected.
//
// Every directive below is pinned exactly, because each of the three
// loosenings is load-bearing and none of them may grow: // loosenings is load-bearing and none of them may grow:
// //
// style-src 'unsafe-inline' src/popup/index.html and the view helpers
// use style="..." attributes throughout, which
// CSP blocks without it. Chrome enforces this
// on attributes, not just <style> blocks, and
// Firefox has never implemented style-src-attr,
// so there is no narrower spelling available.
// img-src data: blockies are data: PNGs assigned to img.src. // img-src data: blockies are data: PNGs assigned to img.src.
// connect-src https: http: the RPC endpoint is user-configurable, and a // connect-src https: http: the RPC endpoint is user-configurable, and a
// local node over http://127.0.0.1 is a // local node over http://127.0.0.1 is a
@@ -57,7 +58,7 @@ const EXPECTED_DIRECTIVES = {
"default-src": ["'self'"], "default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval'"], "script-src": ["'self'", "'wasm-unsafe-eval'"],
"object-src": ["'self'"], "object-src": ["'self'"],
"style-src": ["'self'"], "style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"], "img-src": ["'self'", "data:"],
"connect-src": ["'self'", "http:", "https:"], "connect-src": ["'self'", "http:", "https:"],
"frame-src": ["'none'"], "frame-src": ["'none'"],
-461
View File
@@ -1,461 +0,0 @@
// 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);
});
},
);
-22
View File
@@ -722,28 +722,6 @@ describe("the base profile the sweep corrupts", () => {
} }
}); });
// Home, AddressDetail and AddressToken load their transactions inside a catch
// that only logs, so a boot that fails there still renders the view and passes
// the tests above while none of that code runs.
describe("the base profile loads transactions", () => {
for (const view of ["main", "address", "address-token"]) {
test(`on ${view} without logging a failure`, async () => {
const consoleError = jest.spyOn(console, "error");
try {
await bootPopup(restoringOnto(view));
const failures = consoleError.mock.calls
.map((args) => args.join(" "))
.filter((line) =>
/loadHomeTxs failed|loadTransactions failed/.test(line),
);
expect(failures).toEqual([]);
} finally {
consoleError.mockRestore();
}
});
}
});
// A field the ROUTER itself reads — the two it gates on and the two // A field the ROUTER itself reads — the two it gates on and the two
// hasValidAddress() indexes with. A hostile value in one of these legitimately // hasValidAddress() indexes with. A hostile value in one of these legitimately
// changes which view renders, so each gets its own boot per view and is held // changes which view renders, so each gets its own boot per view and is held
-530
View File
@@ -1,530 +0,0 @@
// The Send screen's "Max" control
// (https://git.eeqj.de/sneak/AutistMask/issues/198).
//
// For ETH it fills in the exact balance minus the fee reserve the
// confirmation screen's balance check gates on, never the four-decimal balance
// the Send screen shows; the confirmation screen re-derives that amount from
// its own estimate, and signs with that estimate's fee fields. For a token it
// fills in the whole balance, and the check that ETH covers the fee still
// applies.
//
// Driven through the real refreshBalances(), Send screen and confirmation
// screen, Sign & Send included, with only the node, the explorer and the DOM
// stubbed.
"use strict";
// What the stub node answers, and the signed transactions it was handed.
const mockNode = {
balanceWei: 0n,
feeData: null,
broadcast: [],
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async lookupAddress() {
return null;
}
async getFeeData() {
return mockNode.feeData;
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getNetwork() {
return { chainId: 1n };
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// 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() {},
};
}
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 { Transaction, Wallet, formatEther } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A second address of the wallet, and a second recipient.
const OTHER = "0x" + "e".repeat(40);
const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n;
const GAS = 21000n;
// The Send screen shows this balance as 1.2345 ETH.
const BALANCE_WEI = 1234567890123456789n;
// A token the bundled list does not know, with enough holders to be listed.
const TOKEN = "0x" + "d".repeat(40);
// Fee data whose reserve is 21000 gas at `maxFeePerGas`. The expected cost,
// at gasPrice, is lower, as it is on mainnet.
function fees(maxFeePerGas) {
return {
maxFeePerGas,
maxPriorityFeePerGas: GWEI,
gasPrice: maxFeePerGas / 2n,
};
}
// The balance minus a reserve of 21000 gas at `maxFeePerGas`.
function maxAfter(maxFeePerGas) {
return formatEther(BALANCE_WEI - GAS * maxFeePerGas);
}
function el(id) {
return global.document.getElementById(id);
}
function text(id) {
return el(id).textContent;
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockExplorer.items = tokenItems;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret: await encryptWithPassword(PRIVATE_KEY, PASSWORD),
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
}
function tokenRow(value, decimals = "18") {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals,
holders_count: "50000",
},
};
}
// The confirmation screen Review leads to, once shown.
let confirmed = null;
// Open the Send screen for `token` ("ETH" or a token address), with the
// recipient entered.
function openSend(token = "ETH") {
send.init({ showConfirmTx: (info) => (confirmed = info) });
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
}
async function pressMax() {
await el("btn-send-max").handlers.get("click")();
}
// Press Review and show the confirmation screen with its fee estimate settled.
async function review() {
await el("btn-send-review").handlers.get("click")();
confirmTx.show(confirmed);
await settle();
}
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function canSend() {
return !el("btn-confirm-send").disabled;
}
beforeEach(() => {
elements.clear();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.feeData = fees(20n * GWEI);
mockNode.broadcast = [];
});
describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI);
openSend();
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 1.2345 ETH");
await pressMax();
// 1.234567890123456789 - 21000 * 20 gwei.
expect(el("send-amount").value).toBe("1.234147890123456789");
});
test("leaves exactly the fee reserve behind, and the confirmation screen enables Send", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
await review();
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
});
test("re-derives the amount when the fee estimate changes", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
// The fee rises between Max and the confirmation screen's estimate.
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI);
confirmTx.restore();
await settle();
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(canSend()).toBe(true);
});
test("is signed with the fee its amount leaves behind", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
await review();
// The fee the node quotes rises after the estimate. Fetched afresh
// at signing, it would make amount plus fee more than the balance.
mockNode.feeData = fees(25n * GWEI);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(mockNode.broadcast).toHaveLength(1);
const tx = Transaction.from(mockNode.broadcast[0]);
expect(tx.to).toBe(RECIPIENT);
expect(tx.maxFeePerGas).toBe(20n * GWEI);
expect(tx.value + tx.gasLimit * tx.maxFeePerGas).toBe(BALANCE_WEI);
});
test("says so instead of filling in an amount when the balance does not cover the fee", async () => {
// 0.0001 ETH against a reserve of 0.00042 ETH.
await refreshWith(100000000000000n);
openSend();
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe(
"Your balance does not cover the network fee.",
);
});
test("asks for the recipient first, since the fee depends on it", async () => {
await refreshWith(BALANCE_WEI);
openSend();
el("send-to").value = "";
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe(
"Please enter a recipient address first.",
);
});
// Holds the node's fee answer, so Max's estimate is still running, until
// the returned function is called.
function holdFeeEstimate() {
let release;
mockNode.feeData = new Promise((resolve) => {
release = () => resolve(fees(20n * GWEI));
});
return release;
}
test.each([
["the same address", 0],
["another address", 1],
])(
"fills nothing in once Send was left and opened again for %s while the fee was estimated",
async (_, addressIndex) => {
await refreshWith(BALANCE_WEI);
state.wallets[0].addresses.push({
address: OTHER,
balance: "2.0",
tokenBalances: [],
});
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
// Back, then Send again as home.js opens it, with the same
// recipient typed in again.
state.selectedAddress = addressIndex;
el("send-to").value = "";
el("send-amount").value = "";
send.resetSendValidation();
el("send-to").value = RECIPIENT;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
},
);
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
// 0.0001 ETH, which does not cover the fee: a result that landed
// would say so on whichever screen is shown.
await refreshWith(100000000000000n);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
state.currentView = "home";
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills nothing in when the recipient changed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-to").value = OTHER;
release();
await pressed;
expect(el("send-amount").value).toBe("");
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();
const release = holdFeeEstimate();
const pressed = pressMax();
release();
await pressed;
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
});
test("typed over, is an ordinary amount the confirmation screen keeps", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
el("send-amount").value = "0.5";
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe("0.5 ETH");
});
});
describe("Max on a token send", () => {
// 1234.567890123456789012 TOK; the Send screen shows 1234.5678.
const TOKEN_UNITS = 1234567890123456789012n;
test("fills in the whole token balance", async () => {
await refreshWith(BALANCE_WEI, [tokenRow(TOKEN_UNITS)]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
test("of a token with more than 18 decimal places, fills in the balance cut down to the 18 the confirmation screen accepts", async () => {
// 1234.567890123456789012999999 TOK at 24 decimal places.
await refreshWith(BALANCE_WEI, [
tokenRow(1234567890123456789012999999n, "24"),
]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
test("is still refused when ETH cannot cover the fee", async () => {
await refreshWith(0n, [tokenRow(TOKEN_UNITS)]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(el("confirm-gas-error").style.visibility).toBe("visible");
expect(canSend()).toBe(false);
});
test("says so when the token balance is unknown", async () => {
// No scale from the explorer, the bundled list or a tracked token.
const row = tokenRow(TOKEN_UNITS);
delete row.token.decimals;
await refreshWith(BALANCE_WEI, [row]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("This token's balance is unknown.");
});
test("says so when the token balance is zero", async () => {
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
];
await refreshWith(BALANCE_WEI, [tokenRow(0n)]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("This token's balance is zero.");
});
});
-77
View File
@@ -423,80 +423,3 @@ describe("two wallets independently created with a colliding identity", () => {
expect(secrets).toContain("secret-b"); expect(secrets).toContain("secret-b");
}); });
}); });
// showView() saves on every navigation without waiting, so the user can change
// something while that save is still waiting on storage. The change is followed
// by its own saveState(), which runs after the first save; it must be stored
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
describe("a change made while an earlier save from the same page is running", () => {
// Runs `change` inside the next call to `op` (the stub's get or set),
// before that call does its work.
function runInside(op, change) {
const real = op.getMockImplementation();
op.mockImplementationOnce(async (arg) => {
change();
return real(arg);
});
}
test("a network switched during the earlier save's read is stored", async () => {
const storage = makeStorageStub({
autistmask: { wallets: [W1], networkId: "sepolia" },
});
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.get, () => {
state.networkId = "mainnet";
queued = saveState();
});
state.theme = "dark";
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.theme).toBe("dark");
expect(stored.networkId).toBe("mainnet");
});
test("a wallet added during the earlier save's read is stored", async () => {
const storage = makeStorageStub({ autistmask: { wallets: [W1] } });
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.get, () => {
state.wallets.push(W2);
queued = saveState();
});
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
test("a wallet added during the earlier save's write is stored", async () => {
const storage = makeStorageStub({ autistmask: { wallets: [W1] } });
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.set, () => {
state.wallets.push(W2);
queued = saveState();
});
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
-167
View File
@@ -148,173 +148,6 @@ describe("the destructive reset on the recovery screen", () => {
}); });
}); });
describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
// on the wallet list with the last good balances, and only a reopen
// reached the recovery screen.
test("moves to the recovery screen at its next refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.visibleViews()).toEqual(["main"]);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
expect(env.hidden("btn-settings")).toBe(true);
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
});
test("stops the ten-second refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
const { refreshBalances } = require("../src/shared/balances");
const calls = refreshBalances.mock.calls.length;
await env.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls);
});
test("a later save does not clear what the user exported or typed", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.tick();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// Such as the save of a refresh already in flight when the screen
// went up.
const { saveState } = require("../src/shared/state");
await expect(saveState()).rejects.toThrow();
await env.settle();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
// The record can become readable again under this popup, erased from the
// recovery screen of another window, so a save from this one can succeed.
test("a popup opened after the record is erased elsewhere shows a screen", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
await env.storage.remove("autistmask");
const { saveState } = require("../src/shared/state");
await saveState();
const reopened = await bootPopup(env.storage.read("autistmask"));
expect(reopened.visibleViews()).toEqual(["welcome"]);
});
test("a stored current view of the recovery screen does not blank the popup", async () => {
const env = await bootPopup(
unversionedValidProfile({ currentView: "state-recovery" }),
);
expect(env.visibleViews()).toEqual(["main"]);
});
test("a transaction wait that ends under it does not replace it", async () => {
const env = await bootPopup(
unversionedValidProfile({
currentView: "wait-tx",
viewData: {
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
broadcastTime: Date.now(),
},
},
}),
);
expect(env.visibleViews()).toEqual(["wait-tx"]);
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.tick();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// The test provider answers no receipt lookup, and six that fail in
// a row end the wait with an error.
for (let i = 0; i < 6; i++) await env.tick();
expect(env.text("error-tx-message")).toMatch(/could not be reached/);
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
test("a storage read that fails once leaves the wallet list up", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.local.get.mockRejectedValueOnce(
new Error("IO error: storage busy"),
);
await env.tick();
// Reported as a failed save, not mistaken for an unreadable profile.
expect(env.visibleViews()).toEqual(["main"]);
expect(env.node("save-failure-banner")).not.toBeNull();
await env.tick();
expect(env.visibleViews()).toEqual(["main"]);
});
// The screen it replaces is left as any navigation leaves it: the rules
// at the top of src/popup/views/showPhrase.js and exportPrivkey.js hold
// for this way off them too.
describe("from a screen holding a secret", () => {
const PHRASE =
"abandon abandon abandon abandon abandon abandon abandon" +
" abandon abandon abandon abandon about";
afterEach(() => jest.dontMock("../src/shared/vault"));
test("a recovery phrase on screen is wiped", async () => {
jest.doMock("../src/shared/vault", () => ({
decryptWithPassword: async () => PHRASE,
}));
const env = await bootPopup(unversionedValidProfile());
require("../src/popup/views/showPhrase").show(0);
env.node("show-phrase-password").value = "password";
await env.click("btn-show-phrase-reveal");
expect(env.text("show-phrase-value")).toBe(PHRASE);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("show-phrase-value")).toBe("");
});
test("a private key still being decrypted is never written", async () => {
let answer;
jest.doMock("../src/shared/vault", () => ({
decryptWithPassword: () =>
new Promise((resolve) => {
answer = resolve;
}),
}));
const env = await bootPopup(unversionedValidProfile());
require("../src/popup/views/exportPrivkey").show(0, 0);
env.node("export-privkey-password").value = "password";
const revealing = env.click("btn-export-privkey-confirm");
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.value("export-privkey-password")).toBe("");
answer(PHRASE);
await revealing;
expect(env.text("export-privkey-value")).toBe("");
});
});
});
describe("an unversioned profile that is perfectly valid", () => { describe("an unversioned profile that is perfectly valid", () => {
// The upgrade case. Every install in the field is in this state, and the // The upgrade case. Every install in the field is in this state, and the
// popup must load it, not offer to wipe it. // popup must load it, not offer to wipe it.
+8 -30
View File
@@ -40,10 +40,6 @@ jest.doMock("libsodium-wrappers-sumo", () => sodium);
jest.doMock("qrcode", () => QRCode); jest.doMock("qrcode", () => QRCode);
jest.doMock("ethereum-blockies-base64", () => makeBlockie); jest.doMock("ethereum-blockies-base64", () => makeBlockie);
// Taken before any boot replaces them; see bootPopup().
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
const POPUP_HTML = fs.readFileSync( const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"), path.join(__dirname, "..", "..", "src", "popup", "index.html"),
"utf8", "utf8",
@@ -253,6 +249,8 @@ async function bootPopup(stored, options) {
formatUsd: () => "", formatUsd: () => "",
formatAddressTotal: () => "", formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }), getAddressValue: () => ({ usd: null, partial: false }),
getWalletValue: () => ({ usd: null, partial: false }),
getTotalValue: () => ({ usd: null, partial: false }),
})); }));
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
@@ -261,12 +259,9 @@ async function bootPopup(stored, options) {
getProvider: () => ({}), getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []), scanForAddresses: jest.fn(async () => []),
})); }));
// filterTransactions() answers in the real one's shape: Home,
// AddressDetail and AddressToken read both fields, and a bare list makes
// their transaction loading throw into a catch that only logs.
jest.doMock("../../src/shared/transactions", () => ({ jest.doMock("../../src/shared/transactions", () => ({
fetchRecentTransactions: jest.fn(async () => []), fetchRecentTransactions: jest.fn(async () => []),
filterTransactions: () => ({ transactions: [], newFraudContracts: [] }), filterTransactions: () => [],
})); }));
const storage = const storage =
@@ -297,20 +292,9 @@ async function bootPopup(stored, options) {
}), }),
addEventListener: () => {}, addEventListener: () => {},
}; };
// The ten-second refresh init() starts, and a transaction wait's timers, // The 10s refresh loop init() starts would outlive the test.
// would outlive the test. So every interval is recorded rather than const realSetInterval = globalThis.setInterval;
// started, clearInterval() removes it as a browser would, and tick() below globalThis.setInterval = () => 0;
// runs the ones still set. Put back by cleanupPopup().
const intervals = new Map();
let lastId = 0;
globalThis.setInterval = (fn) => {
lastId += 1;
intervals.set(lastId, fn);
return lastId;
};
globalThis.clearInterval = (id) => {
intervals.delete(id);
};
require("../../src/popup/index"); require("../../src/popup/index");
@@ -332,6 +316,8 @@ async function bootPopup(stored, options) {
} }
await settle(); await settle();
globalThis.setInterval = realSetInterval;
return { return {
storage, storage,
document, document,
@@ -351,12 +337,6 @@ async function bootPopup(stored, options) {
for (const fn of fns) await fn(); for (const fn of fns) await fn();
await settle(); await settle();
}, },
// Every interval still set runs once: the ten-second refresh, and a
// transaction wait's receipt poll and elapsed counter while one runs.
tick: async () => {
for (const fn of intervals.values()) await fn();
await settle();
},
settle, settle,
// The view ids whose section is not hidden, as the audit measured them. // The view ids whose section is not hidden, as the audit measured them.
visibleViews: () => { visibleViews: () => {
@@ -374,8 +354,6 @@ function cleanupPopup() {
delete globalThis.chrome; delete globalThis.chrome;
delete globalThis.document; delete globalThis.document;
delete globalThis.window; delete globalThis.window;
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
} }
module.exports = { module.exports = {
-159
View File
@@ -1,159 +0,0 @@
// 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)");
});
+13 -66
View File
@@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("queries only the two Blockscout endpoints for the address", async () => { test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []); respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(debugFetch).toHaveBeenCalledTimes(2); expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]); const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain( expect(urls).toContain(
@@ -1283,7 +1283,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
const merged = txs[0]; const merged = txs[0];
// The received leg (the swap output) supplies the display amount. // The received leg (the swap output) supplies the display amount.
@@ -1320,7 +1320,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC"); expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000"); expect(txs[0].value).toBe("1500.5000");
@@ -1346,7 +1346,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}); });
respondWith([], [leg("1000000"), leg("2000000")]); respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins. // Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0"); expect(txs[0].exactValue).toBe("2.0");
@@ -1386,7 +1386,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
}); });
@@ -1427,13 +1427,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC"); expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0"); expect(txs[0].exactValue).toBe("1.0");
expect(txs[0].direction).toBe("sent"); expect(txs[0].direction).toBe("sent");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT); expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
expect(txs[0].chainId).toBe("0x1");
// The surviving row is the token row, and the filters keep it. // The surviving row is the token row, and the filters keep it.
const kept = filterTransactions(txs, filters()).transactions; const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1); expect(kept).toHaveLength(1);
@@ -1453,46 +1452,10 @@ describe("fetchRecentTransactions merge and dedup", () => {
}); });
respondWith([item(6), item(8), item(7)], []); respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 2); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]); expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/372: the caller hands in
// the chain id of the network the explorer serves. Every entry carries
// it, and a native entry is labelled with that network's nativeCurrency.
test("a native entry is labelled by the chain id handed in", async () => {
respondWith(
[
{
hash: "0x" + "a".repeat(64),
block_number: 21000090,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM, is_contract: false },
value: "10000000000000000",
method: null,
status: "ok",
},
],
[],
);
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0xaa36a7",
);
expect(sepolia[0].symbol).toBe("SepoliaETH");
expect(sepolia[0].value).toBe("0.0100");
expect(sepolia[0].chainId).toBe("0xaa36a7");
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(mainnet[0].symbol).toBe("ETH");
expect(mainnet[0].chainId).toBe("0x1");
});
test("the fake token transfer survives fetching and is then filtered", async () => { test("the fake token transfer survives fetching and is then filtered", async () => {
respondWith( respondWith(
[], [],
@@ -1513,7 +1476,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT); expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0); expect(txs[0].holders).toBe(0);
@@ -1552,31 +1515,19 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("an omitted holders_count parses to null", async () => { test("an omitted holders_count parses to null", async () => {
respondWith([], spamTransferWithToken(OMITTED)); respondWith([], spamTransferWithToken(OMITTED));
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(txs[0].holders).toBeNull(); expect(txs[0].holders).toBeNull();
}); });
test("a null holders_count parses to null", async () => { test("a null holders_count parses to null", async () => {
respondWith([], spamTransferWithToken(NULLED)); respondWith([], spamTransferWithToken(NULLED));
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(txs[0].holders).toBeNull(); expect(txs[0].holders).toBeNull();
}); });
test("the transfer survives the low-holder filter", async () => { test("the transfer survives the low-holder filter", async () => {
respondWith([], spamTransferWithToken(OMITTED)); respondWith([], spamTransferWithToken(OMITTED));
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(filterTransactions(txs, filters()).transactions).toEqual( expect(filterTransactions(txs, filters()).transactions).toEqual(
txs, txs,
); );
@@ -1588,11 +1539,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
// holder count is the only rule that can catch it. // holder count is the only rule that can catch it.
test('a reported holders_count of "0" still parses to 0 and is filtered', async () => { test('a reported holders_count of "0" still parses to 0 and is filtered', async () => {
respondWith([], spamTransferWithToken(ZERO)); respondWith([], spamTransferWithToken(ZERO));
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(txs[0].holders).toBe(0); expect(txs[0].holders).toBe(0);
expect(filterTransactions(txs, filters()).transactions).toEqual([]); expect(filterTransactions(txs, filters()).transactions).toEqual([]);
}); });
@@ -1608,7 +1555,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}, },
})); }));
await expect( await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"), fetchRecentTransactions(VICTIM, BLOCKSCOUT),
).resolves.toEqual([]); ).resolves.toEqual([]);
}); });
+2 -12
View File
@@ -4,7 +4,7 @@
// contract at signing time, with nothing comparing the two, so a token whose // contract at signing time, with nothing comparing the two, so a token whose
// on-chain scale differed signed an amount that was never displayed. // on-chain scale differed signed an amount that was never displayed.
const { formatUnits, parseUnits } = require("ethers"); const { parseUnits } = require("ethers");
const { const {
displayedDecimals, displayedDecimals,
transferAmountUnits, transferAmountUnits,
@@ -25,17 +25,7 @@ describe("displayedDecimals", () => {
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS); expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
}); });
// decimals() is a uint8, but formatUnits() and parseUnits() stop at 80 test("refuses anything that is not a uint8", () => {
// places, so a larger scale cannot be shown or encoded
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("accepts exactly the scales the formatter accepts", () => {
expect(() => formatUnits(1n, MAX_DECIMALS)).not.toThrow();
expect(() => parseUnits("1", MAX_DECIMALS)).not.toThrow();
expect(() => formatUnits(1n, MAX_DECIMALS + 1)).toThrow();
expect(() => parseUnits("1", MAX_DECIMALS + 1)).toThrow();
});
test("refuses anything that is not a uint8 the formatter accepts", () => {
for (const bad of [ for (const bad of [
null, null,
undefined, undefined,
-68
View File
@@ -6,8 +6,6 @@ const {
FEE_UNAVAILABLE, FEE_UNAVAILABLE,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
} = require("../src/shared/txValidation"); } = require("../src/shared/txValidation");
@@ -308,72 +306,6 @@ describe("feeEstimateWei", () => {
}); });
}); });
// The amount the Send screen's Max fills in for ETH: the exact balance, as
// balances.js stores it, minus the fee reserve. Never the four-decimal balance
// the Send screen shows.
describe("maxEthAmount", () => {
// The Send screen shows this balance as 1.2345.
const BALANCE = "1.234567890123456789";
test("is the exact balance minus the fee, to the wei", () => {
expect(maxEthAmount(BALANCE, FEE)).toBe("1.234147890123456789");
expect(
parseEther(BALANCE) - parseEther(maxEthAmount(BALANCE, FEE)),
).toBe(FEE);
});
test("passes validateTransfer with exactly the fee left behind", () => {
const r = validateTransfer({
isErc20: false,
amount: maxEthAmount(BALANCE, FEE),
ethBalance: BALANCE,
feeStatus: FEE_KNOWN,
feeWei: FEE,
});
expect(r).toEqual({ canSend: true, codes: [] });
});
test("is one wei when the balance is one wei more than the fee", () => {
expect(maxEthAmount("0.000420000000000001", FEE)).toBe(
"0.000000000000000001",
);
});
test("is null when the balance does not cover the fee", () => {
expect(maxEthAmount("0.0001", FEE)).toBe(null);
expect(maxEthAmount("0.0", FEE)).toBe(null);
});
test("is null when the balance covers the fee and nothing more", () => {
expect(maxEthAmount("0.00042", FEE)).toBe(null);
});
test("is null on a balance or fee it cannot do exact arithmetic on", () => {
expect(maxEthAmount(undefined, FEE)).toBe(null);
expect(maxEthAmount("not a number", FEE)).toBe(null);
expect(maxEthAmount(BALANCE, null)).toBe(null);
expect(maxEthAmount(BALANCE, -1n)).toBe(null);
expect(maxEthAmount(BALANCE, 420000000000000)).toBe(null);
});
});
// The amount the Send screen's Max fills in for a token.
describe("maxTokenAmount", () => {
test("cuts a balance with more than 18 places down, never up", () => {
const amount = maxTokenAmount("1234.567890123456789012999999");
expect(amount).toBe("1234.567890123456789012");
expect(toFixedPoint(amount)).not.toBe(null);
});
test("leaves a balance with 18 places or fewer as it is", () => {
expect(maxTokenAmount("0.123456789012345678")).toBe(
"0.123456789012345678",
);
expect(maxTokenAmount("1.5")).toBe("1.5");
expect(maxTokenAmount("100")).toBe("100");
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does. // Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no // Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out. // fee at all, on a full-balance send, in the direction that lets money out.
-125
View File
@@ -554,33 +554,6 @@ describe("uniswap decoder", () => {
); );
}); });
test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
});
// A JavaScript date cannot hold this deadline. It used to make the whole
// swap undecoded.
test("a deadline of the uint256 maximum is stated in words", () => {
const data = buildExecute(
"0x08", // V2_SWAP_EXACT_IN
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
],
2n ** 256n - 1n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
expect(detail(result, "Deadline").value).toBe(
"After 275760-09-13 00:00:00 (no deadline in practice)",
);
});
test("formats permit amount when not unlimited", () => { test("formats permit amount when not unlimited", () => {
const data = buildExecute( const data = buildExecute(
"0x0a", "0x0a",
@@ -858,104 +831,6 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
// the swap uses all of them. Against 375998b this read "0.0000 USDT".
test("a V2 exact-in already-paid amountIn is named, not printed as zero", () => {
const data = buildExecute(
"0x08",
[
encodeV2SwapExactIn(
USER_ADDR,
0n, // Constants.ALREADY_PAID
500000000000000n,
[USDT_ADDR, WETH_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("USDT");
expect(detail(result, "Amount").value).toBe(
"Whatever an earlier step sent to the pair (V2 already paid)",
);
expect(detail(result, "Amount").rawValue).toBe(
"Whatever an earlier step sent to the pair (V2 already paid)",
);
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router passes a
// BALANCE_CHECK_ERC20 whenever the balance is at least minBalance, so a
// zero one guarantees nothing. Against 375998b it replaced the swap's
// output side: Token Out = USDC, Min. received = "None (no minimum
// guaranteed)".
test("a zero balance check keeps the minimum a swap step stated", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 0n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("WETH");
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
});
// A nonzero balance check still replaces the output side, as before.
test("a nonzero balance check replaces the minimum a swap step stated", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 2000000n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
});
// With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 0n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
});
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without // Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no // testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
// output currency, so it says so and titles itself "Uniswap Swap" rather // output currency, so it says so and titles itself "Uniswap Swap" rather
-13
View File
@@ -126,19 +126,6 @@ describe("a swap of a token outside the bundled list", () => {
test("a bundled token on the other side still formats", () => { test("a bundled token on the other side still formats", () => {
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH"); expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
}); });
// A token added by hand carries whatever its decimals() returned, and a
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
// whole swap undecoded rather than refused
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("refuses to format when the token reports more than 80 decimals", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 81 },
];
expect(swapDetail(data(), "Amount").value).toBe(
"1000000000 base units (decimals unknown)",
);
});
}); });
describe("the Min. received line takes the same rule", () => { describe("the Min. received line takes the same rule", () => {
+2 -294
View File
@@ -4,16 +4,8 @@
// already in storage: the import that created it ran before the refusal // 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 // 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 // send screen instead. These tests pin down that it is named and explained in
// the wallet list, that every control leading to a signature or to the private // the wallet list, that nothing on the way there throws, and that a wallet
// key refuses it before asking for a password, that nothing on the way there // imported from a real master key is untouched by any of it.
// 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"); const { HDNodeWallet, Mnemonic } = require("ethers");
@@ -245,290 +237,6 @@ 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", () => { describe("no path throws an unhandled error for a defective wallet", () => {
test("address derivation from the stored xpub still works", () => { test("address derivation from the stored xpub still works", () => {
// The stored xpub is at a non-standard depth but is a valid extended // The stored xpub is at a non-standard depth but is a valid extended