Compare commits
1
Commits
next
..
9e5bef1f37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5bef1f37 |
@@ -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
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
|
.claude/
|
||||||
|
|||||||
+6
-5
@@ -8,11 +8,12 @@ WORKDIR /app
|
|||||||
# image sets it.
|
# image sets it.
|
||||||
ENV AUTISTMASK_LINT_NATIVE=1
|
ENV AUTISTMASK_LINT_NATIVE=1
|
||||||
|
|
||||||
# script/test's default 30s bound is the host figure. In here the same suite
|
# script/test's default 30s bound is the host figure, against a suite that
|
||||||
# starts on a cold jest cache and shares the runner with the rest of the build,
|
# runs in about 8s there. In here the same suite starts on a cold jest cache
|
||||||
# so 30s is too tight — it killed a healthy suite at 30.6s on a cold CI cache.
|
# and shares the runner with the rest of the build, so 30s is marginal rather
|
||||||
# 180s still catches a hang in three minutes and cannot be tripped by a suite
|
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
||||||
# that is merely running on contended hardware.
|
# still catches a hang in three minutes and cannot be tripped by a suite that
|
||||||
|
# is merely running on contended hardware.
|
||||||
ENV AUTISTMASK_TEST_TIMEOUT=180
|
ENV AUTISTMASK_TEST_TIMEOUT=180
|
||||||
|
|
||||||
# script/bootstrap installs all prerequisites (make via apt here; node
|
# script/bootstrap installs all prerequisites (make via apt here; node
|
||||||
|
|||||||
@@ -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,10 +619,14 @@ 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
|
||||||
@@ -646,9 +636,7 @@ suite outright if its network interception is not in effect.
|
|||||||
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
|
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
|
||||||
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
|
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
|
||||||
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
||||||
Firefox and geckodriver downloads the same way. The `e2e-chrome` figures predate
|
Firefox and geckodriver downloads the same way.
|
||||||
the two cases that wait for a receipt to end in error, which add about two
|
|
||||||
minutes of real waiting.
|
|
||||||
|
|
||||||
### Element id guard (part of `make check`)
|
### Element id guard (part of `make check`)
|
||||||
|
|
||||||
@@ -707,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)
|
||||||
@@ -814,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
|
||||||
@@ -856,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
|
||||||
@@ -918,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
|
||||||
@@ -959,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
|
||||||
@@ -985,13 +949,6 @@ and compare against. Reading the stored field directly instead answers `null`
|
|||||||
for a bundled or tracked token the explorer merely omitted, which is not a
|
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||||
refusal the wallet has any reason to make.
|
refusal the wallet has any reason to make.
|
||||||
|
|
||||||
The Send screen also consults every address's explorer reports, so a contract
|
|
||||||
two addresses report different `decimals` for has no scale there, and the stored
|
|
||||||
balance, formatted at one of those scales, is withdrawn with it. The Send
|
|
||||||
screen's `Current balance` and the confirmation screen's balance line then both
|
|
||||||
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
|
|
||||||
fetched, without that cross-address check, and shows the row's figure.
|
|
||||||
|
|
||||||
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
||||||
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
||||||
decoded swap (see TxApproval below), do not always read as a number. They can
|
decoded swap (see TxApproval below), do not always read as a number. They can
|
||||||
@@ -1005,59 +962,27 @@ read:
|
|||||||
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
|
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
|
||||||
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
|
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
|
||||||
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
|
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
|
||||||
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
|
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
|
||||||
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
|
or `WRAP_ETH` amount that large, which is not an allowance. The router's
|
||||||
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
|
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
|
||||||
amount.
|
|
||||||
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
|
|
||||||
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
|
||||||
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
|
||||||
not necessarily all of it; the wait, success and error screens show it with
|
|
||||||
the same words. `Unlimited`, `All available (V4 open delta)` and
|
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
|
|
||||||
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
|
||||||
`amountOut`, the exact amount it buys.
|
|
||||||
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
||||||
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
|
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
|
||||||
whole open delta", so the calldata states no quantity. The line shows the
|
whole open delta", so the calldata states no quantity. The line shows the
|
||||||
amount of one step that names an input token or amount: the last
|
amount of one step that names an input token or amount: the last
|
||||||
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
|
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
|
||||||
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
|
||||||
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
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
|
||||||
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
|
that side. The token permission warning on the signature screen has its own
|
||||||
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
|
amount wording, including `Unknown`; the SignApproval section below describes
|
||||||
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
|
it.
|
||||||
`Min. received` keep the output side's own token and figure, whether the swap
|
|
||||||
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
|
|
||||||
unwraps the WETH it did not spend shows USDC. The token permission warning on
|
|
||||||
the signature screen has its own amount wording, including `Unknown`; the
|
|
||||||
SignApproval section below describes it.
|
|
||||||
|
|
||||||
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
|
|
||||||
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
|
|
||||||
UTC, so a later deadline, such as the `uint256` maximum, reads
|
|
||||||
`After 275760-09-13 00:00:00 (no deadline in practice)` rather than leaving the
|
|
||||||
whole swap undecoded.
|
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
@@ -1137,21 +1062,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
|
||||||
|
|
||||||
@@ -1171,18 +1090,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
|
||||||
@@ -1219,16 +1136,15 @@ each caught only by a reviewer re-deriving thirty fields by hand.
|
|||||||
The `allowedSites` case is why the entry check is not optional. A stored
|
The `allowedSites` case is why the entry check is not optional. A stored
|
||||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||||
passed the gate, rendered a completely healthy popup, and then threw inside
|
passed the gate, rendered a completely healthy popup, and then threw inside
|
||||||
`saveState()`'s per-origin merge, so every save from that moment on failed and
|
`saveState()`'s per-hostname merge, so every save from that moment on failed and
|
||||||
the user went on operating a wallet that was persisting nothing
|
the user went on operating a wallet that was persisting nothing
|
||||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
||||||
now also reported rather than swallowed: `onSaveFailure()` in
|
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
|
||||||
@@ -1342,10 +1258,7 @@ view would leave a wallet one click from deletion.
|
|||||||
of every wallet, deduplicated by hash and filtered. Each row is three
|
of every wallet, deduplicated by hash and filtered. Each row is three
|
||||||
lines: age and direction, then the counterparty's colour dot (with our own
|
lines: age and direction, then the counterparty's colour dot (with our own
|
||||||
name for it, where it is one of our addresses) and the amount, then the
|
name for it, where it is one of our addresses) and the amount, then the
|
||||||
counterparty's full address on a row of its own. A contract creation has
|
counterparty's full address on a row of its own
|
||||||
no counterparty: its second line is the amount alone and its third line
|
|
||||||
says "This transaction creates a new contract. It has no recipient." The
|
|
||||||
transaction lists on AddressDetail and AddressToken draw the same rows
|
|
||||||
- "Add additional wallet..." link at bottom
|
- "Add additional wallet..." link at bottom
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Tap address row → sets the active address and broadcasts
|
- Tap address row → sets the active address and broadcasts
|
||||||
@@ -1460,9 +1373,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
|
||||||
@@ -1481,20 +1392,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- What to send: token dropdown (or static display with contract address when
|
- What to send: token dropdown (or static display with contract address when
|
||||||
locked from AddressToken)
|
locked from AddressToken)
|
||||||
- To: address or ENS name input, with an inline validation message
|
- To: address or ENS name input, with an inline validation message
|
||||||
- Amount input with current balance display, which reads
|
- Amount input with current balance display
|
||||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
|
||||||
ConfirmTx's balance line does (see Unknown token scale)
|
|
||||||
- "Max" button beside the amount input, always in place. It fills in a
|
|
||||||
token's balance, cut down to the 18 decimal places ConfirmTx accepts for a
|
|
||||||
token that has more, or for ETH the exact balance minus the network fee
|
|
||||||
reserve that ConfirmTx's balance check gates on, never the rounded balance
|
|
||||||
shown above it. The ETH fee is estimated for the recipient entered, so it
|
|
||||||
asks for a recipient first; an estimate that finishes after the screen was
|
|
||||||
left or the address, holding or recipient changed fills nothing in. Where
|
|
||||||
there is nothing to fill in, a flash message says why: the balance does
|
|
||||||
not cover the fee, the fee could not be estimated, or the token's balance
|
|
||||||
is unknown or zero. Typing in the amount makes it an ordinary amount;
|
|
||||||
changing what to send clears an amount Max filled in
|
|
||||||
- "Review" button, disabled until the recipient validates
|
- "Review" button, disabled until the recipient validates
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||||
@@ -1511,16 +1409,8 @@ 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
|
- Your balance: value + symbol (USD in parentheses)
|
||||||
arrives, as the balance minus the reserve, and the transaction is signed
|
|
||||||
with that estimate's fee fields, so a fee fetched again at signing cannot
|
|
||||||
exceed what the amount leaves behind. The address keeps whatever part of
|
|
||||||
the reserve the transaction does not use. If the balance no longer covers
|
|
||||||
the fee, the amount is left as it was and the amount-plus-fee error below
|
|
||||||
blocks the send
|
|
||||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
|
||||||
for a token whose scale is unknown
|
|
||||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||||
fetched async. The first line is what the transfer is expected to cost,
|
fetched async. The first line is what the transfer is expected to cost,
|
||||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||||
@@ -1536,11 +1426,7 @@ view would leave a wallet one click from deletion.
|
|||||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
||||||
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
||||||
estimated. The first two are mutually exclusive per transfer type, so only
|
estimated. The first two are mutually exclusive per transfer type, so only
|
||||||
the applicable one holds space. The last names its cause: for a token
|
the applicable one holds space
|
||||||
whose scale is unknown the fee can never be estimated, and it says the
|
|
||||||
wallet does not know how many decimal places the token uses and that the
|
|
||||||
transaction cannot be sent; for any other failure it asks the user to go
|
|
||||||
back and try again
|
|
||||||
- Password: an inline field on this screen, not a modal, with its own error
|
- Password: an inline field on this screen, not a modal, with its own error
|
||||||
line
|
line
|
||||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||||
@@ -1565,9 +1451,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link; for a contract creation,
|
- To: color dot + full address + etherscan link
|
||||||
which has no recipient, "This transaction creates a new contract. It has
|
|
||||||
no recipient." instead
|
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Count-up timer: "Waiting for confirmation... Ns"
|
- Count-up timer: "Waiting for confirmation... Ns"
|
||||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||||
@@ -1596,8 +1480,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- Decoded action well (shown when the transaction carried recognized
|
- Decoded action well (shown when the transaction carried recognized
|
||||||
calldata; the top-level Amount and To are hidden in that case)
|
calldata; the top-level Amount and To are hidden in that case)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link, or for a contract creation
|
- To: color dot + full address + etherscan link
|
||||||
the same sentence as on WaitTx
|
|
||||||
- Block number
|
- Block number
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- "Done" button
|
- "Done" button
|
||||||
@@ -1612,8 +1495,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Failed" heading
|
- "Transaction Failed" heading
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link, or for a contract creation
|
- To: color dot + full address + etherscan link
|
||||||
the same sentence as on WaitTx
|
|
||||||
- Error message (dashed border box)
|
- Error message (dashed border box)
|
||||||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||||||
full hash (tap to copy) + etherscan link
|
full hash (tap to copy) + etherscan link
|
||||||
@@ -1646,14 +1528,12 @@ 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
|
||||||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||||||
name if available. For a contract creation, the same sentence as on WaitTx
|
name if available
|
||||||
- Time: ISO datetime + relative age in parentheses
|
- Time: ISO datetime + relative age in parentheses
|
||||||
- Block: block number (tap to copy) + etherscan block link
|
- Block: block number (tap to copy) + etherscan block link
|
||||||
- Amount: value + symbol (bold)
|
- Amount: value + symbol (bold)
|
||||||
@@ -1716,13 +1596,13 @@ view would leave a wallet one click from deletion.
|
|||||||
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
||||||
is refused with a flash message and the field snaps back to the stored
|
is refused with a flash message and the field snaps back to the stored
|
||||||
threshold, so a number the user did not type is never stored.
|
threshold, so a number the user did not type is never stored.
|
||||||
- Allowed Sites: the origins (scheme, host and port) remembered as allowed,
|
- Allowed Sites: the hostnames remembered as allowed, under any address,
|
||||||
under any address, with remove buttons
|
with remove buttons
|
||||||
- Connected Sites: the origins of the sites allowed without "Remember my
|
- Connected Sites: the hostnames of the sites allowed without "Remember my
|
||||||
choice" that are still connected, with remove buttons. Only the background
|
choice" that are still connected, with remove buttons. Only the background
|
||||||
holds these, in memory, and Settings asks it for them with
|
holds these, in memory, and Settings asks it for them with
|
||||||
`AUTISTMASK_GET_CONNECTED_SITES`
|
`AUTISTMASK_GET_CONNECTED_SITES`
|
||||||
- Denied Sites: the origins remembered as denied, under any address, with
|
- Denied Sites: the hostnames remembered as denied, under any address, with
|
||||||
remove buttons
|
remove buttons
|
||||||
- About: project link, license, author, version, release date, and the
|
- About: project link, license, author, version, release date, and the
|
||||||
commit, which links to the commit in the repository
|
commit, which links to the commit in the repository
|
||||||
@@ -1736,11 +1616,10 @@ view would leave a wallet one click from deletion.
|
|||||||
- Tap wallet name → inline rename field (no screen change)
|
- Tap wallet name → inline rename field (no screen change)
|
||||||
- `[x]` on a tracked token → removes it in place (no screen change)
|
- `[x]` on a tracked token → removes it in place (no screen change)
|
||||||
- `[x]` on an allowed or connected site → disconnects that site, in place:
|
- `[x]` on an allowed or connected site → disconnects that site, in place:
|
||||||
its origin is dropped from Allowed Sites under every address, and
|
its hostname is dropped from Allowed Sites under every address, and
|
||||||
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
||||||
without "Remember" from that origin, under any address, and send
|
without "Remember" from an origin with that hostname, under any address,
|
||||||
`accountsChanged` with an empty list to the open tabs of that origin. The
|
and send `accountsChanged` with an empty list to the site's open tabs.
|
||||||
same host under another scheme or port is another site and is left alone.
|
|
||||||
Only the extension's own pages may send either message
|
Only the extension's own pages may send either message
|
||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
||||||
nothing and tells the background nothing
|
nothing and tells the background nothing
|
||||||
@@ -1822,10 +1701,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**:
|
||||||
@@ -1833,9 +1709,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
|
||||||
@@ -1844,13 +1720,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.
|
||||||
|
|
||||||
@@ -1932,22 +1803,14 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||||||
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
||||||
A site is its full origin, `scheme://host[:port]`, on both lists and for a
|
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||||||
connection allowed without "Remember": a choice for `https://dapp.example`
|
falls back to a separate popup window (`src/background/index.js`,
|
||||||
says nothing about `http://dapp.example` or another port of that host. The
|
`requestApproval()`).
|
||||||
background script prefers the toolbar popup (`action.openPopup()`) and falls
|
|
||||||
back to a separate popup window (`src/background/index.js`,
|
|
||||||
`requestApproval()`). Only one exists per site at a time: a further connection
|
|
||||||
request from a site whose prompt is still unanswered is refused with EIP-1193
|
|
||||||
code `-32002` and opens no new prompt. If that prompt was in a toolbar popup
|
|
||||||
that closed before it connected, and the toolbar popup has since been set to
|
|
||||||
open something else, the refused request shows that prompt again.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Connection Request" heading
|
- "Connection Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants to connect to your
|
- Site hostname (bold) + "wants to connect to your wallet"
|
||||||
wallet"
|
|
||||||
- Address that will be shared (color dot + full address + etherscan link)
|
- Address that will be shared (color dot + full address + etherscan link)
|
||||||
- "Remember my choice for this site" checkbox
|
- "Remember my choice for this site" checkbox
|
||||||
- "Allow" / "Deny" buttons
|
- "Allow" / "Deny" buttons
|
||||||
@@ -1966,29 +1829,23 @@ view would leave a wallet one click from deletion.
|
|||||||
programmatically rather than by a user gesture. The background populates the
|
programmatically rather than by a user gesture. The background populates the
|
||||||
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
||||||
opening the window, so the screen shows a complete transaction and the signed
|
opening the window, so the screen shows a complete transaction and the signed
|
||||||
artifact can be compared with it field for field. A nonce the site supplies is
|
artifact can be compared with it field for field. A request that cannot be
|
||||||
ignored: the nonce is always the account's next nonce from the node, so a site
|
populated — unreachable node, reverting gas estimate — opens no window and is
|
||||||
cannot replace one of the user's pending transactions or leave this one stuck
|
failed back to the site. Only one transaction approval exists at a time:
|
||||||
behind a gap. A request that cannot be populated — unreachable node, reverting
|
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
|
||||||
gas estimate — opens no window and is failed back to the site. Only one
|
one is unanswered is refused with EIP-1193 code `-32002` rather than being
|
||||||
transaction approval exists at a time: populating fixes the nonce, so a second
|
populated at the same nonce. It opens no window and takes no nonce, and the
|
||||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
site can send it again once the pending one is answered.
|
||||||
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
|
|
||||||
no window and takes no nonce, and the site can send it again once the pending
|
|
||||||
one is answered. The window is centred on the browser window the user was last
|
|
||||||
in; if that was another approval window, or the browser refuses the centred
|
|
||||||
position, the browser picks the position.
|
|
||||||
- **Elements**:
|
- **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
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants to send a
|
- Site hostname (bold) + "wants to send a transaction"
|
||||||
transaction"
|
|
||||||
- Decoded action (if calldata is recognized): action name, token details,
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
amounts, steps, deadline (see Transaction Decoding)
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
- Contract: color dot + full address + etherscan link (or "contract
|
||||||
known; for a contract creation, the same sentence as on WaitTx
|
creation"), token symbol label if known
|
||||||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||||
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
||||||
in parentheses), with the gas limit and the fee per gas in gwei below it
|
in parentheses), with the gas limit and the fee per gas in gwei below it
|
||||||
@@ -2009,32 +1866,19 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
- **When**: A connected website requests a message signature via
|
- **When**: A connected website requests a message signature via
|
||||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
||||||
TxApproval, in a separate popup window. Only one exists per site at a time: a
|
TxApproval, in a separate popup window.
|
||||||
further signature request, by any of these methods, from a site whose
|
|
||||||
signature request is still unanswered is refused with EIP-1193 code `-32002`
|
|
||||||
and opens no window.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Signature Request" heading
|
- "Signature Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants you to sign a
|
- Site hostname (bold) + "wants you to sign a message"
|
||||||
message"
|
|
||||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||||
decode to as UTF-8, laid out left to right in the order of the bytes that
|
message fields (EIP-712 typed data). The primary type shown is the one
|
||||||
are signed, right-to-left characters included. Each control character,
|
ethers signs, derived from the typed data's `types`, not the type the site
|
||||||
each line or paragraph separator (U+2028, U+2029; left in the text, a
|
states.
|
||||||
paragraph separator would end that layout for the text after it), and each
|
|
||||||
character that paints nothing (format characters such as zero-width and
|
|
||||||
bidirectional ones, default-ignorable characters such as variation
|
|
||||||
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
|
|
||||||
mark instead of acting on the text; a line feed is shown as a line break.
|
|
||||||
Bytes that are not UTF-8 are shown as "This message is not text." For
|
|
||||||
typed data, formatted domain/type/message fields (EIP-712). The primary
|
|
||||||
type shown is the one ethers signs, derived from the typed data's `types`,
|
|
||||||
not the type the site states.
|
|
||||||
- Token permission warning, at the top of the message (typed data whose
|
- Token permission warning, at the top of the message (typed data whose
|
||||||
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
||||||
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
||||||
@@ -2046,20 +1890,12 @@ view would leave a wallet one click from deletion.
|
|||||||
domain's `verifyingContract`; any those fields do not give is shown as
|
domain's `verifyingContract`; any those fields do not give is shown as
|
||||||
`Unknown`, and the domain, type and message lines still follow. Only typed
|
`Unknown`, and the domain, type and message lines still follow. Only typed
|
||||||
data that cannot be read at all is shown as raw text.
|
data that cannot be read at all is shown as raw text.
|
||||||
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
|
|
||||||
the site sent it. The bytes it encodes are what is signed, as an EIP-191
|
|
||||||
personal message.
|
|
||||||
- Password input and an error line
|
- Password input and an error line
|
||||||
- "Sign" / "Reject" buttons
|
- "Sign" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Typed data that states no primary type, or one other than the type it
|
- Typed data that states no primary type, or one other than the type it
|
||||||
would be signed as, or that cannot be read → shown with the error line
|
would be signed as, or that cannot be read → shown with the error line
|
||||||
saying so and "Sign" disabled; only "Reject" remains
|
saying so and "Sign" disabled; only "Reject" remains
|
||||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
|
||||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
|
||||||
signing) → shown as plain text, with the error line "This message is plain
|
|
||||||
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
|
|
||||||
the bytes from the hex, so such a message has none to sign
|
|
||||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||||
signature)
|
signature)
|
||||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||||
@@ -2070,19 +1906,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.
|
||||||
@@ -2109,20 +1935,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
|
||||||
|
|
||||||
@@ -2215,7 +2037,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'
|
||||||
```
|
```
|
||||||
@@ -2227,17 +2049,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
|
||||||
@@ -2296,17 +2116,6 @@ the log level and turns the banner on, and that is all it may ever do: it feeds
|
|||||||
constant directly, so no runtime toggle in a release build can reach the
|
constant directly, so no runtime toggle in a release build can reach the
|
||||||
hardcoded test phrase.
|
hardcoded test phrase.
|
||||||
|
|
||||||
At the raised log level the console also shows the wallet's addresses with their
|
|
||||||
balances and ENS names, the token contracts looked up, and a line for each
|
|
||||||
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
|
|
||||||
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
|
|
||||||
for its response. A request is logged by its HTTP method, the origin of its URL
|
|
||||||
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
|
|
||||||
refresh, the token lookup and a failed endpoint check in settings name the
|
|
||||||
endpoint by its origin too. The URL's path and query string, where RPC providers
|
|
||||||
put API keys, any user name and password in it, and the request body are never
|
|
||||||
logged.
|
|
||||||
|
|
||||||
### Key Decisions
|
### Key Decisions
|
||||||
|
|
||||||
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
||||||
@@ -2433,8 +2242,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.
|
||||||
@@ -2443,8 +2251,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
|
||||||
@@ -2454,9 +2261,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
|
||||||
@@ -2480,9 +2285,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
|
||||||
|
|
||||||
|
|||||||
@@ -45,406 +45,6 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-05: The popup's Content Security Policy no longer allows inline style
|
|
||||||
([#328](https://git.eeqj.de/sneak/AutistMask/issues/328)): `style-src` is
|
|
||||||
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
|
|
||||||
`style="..."` attributes in `src/popup/index.html` and in the markup the view
|
|
||||||
helpers build are now Tailwind classes, each computing to the value it
|
|
||||||
replaced. The 16 address dot colours are written out as whole classes, because
|
|
||||||
Tailwind builds only the classes it finds in the source. The Settings debug
|
|
||||||
well is shown and hidden with the `hidden` class, since clearing an inline
|
|
||||||
`display` no longer uncovers it. Script that sets `element.style` is
|
|
||||||
unaffected.
|
|
||||||
|
|
||||||
- 2026-10-05: `.prettierignore` no longer lists an AI vendor's tool directory
|
|
||||||
([#363](https://git.eeqj.de/sneak/AutistMask/issues/363)). The directory is
|
|
||||||
not tracked, so the line ignored nothing.
|
|
||||||
|
|
||||||
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
|
|
||||||
transaction's receipt ends on the error screen
|
|
||||||
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
|
|
||||||
find no receipt 60 seconds after the broadcast end it with the timeout
|
|
||||||
message, and six lookups that fail in a row end it with the message naming the
|
|
||||||
unreachable network. Done then returns to the address screen. Both cases wait
|
|
||||||
in real time, about a minute each. Playwright's clock would apply to every
|
|
||||||
later test in the run and cannot be removed, and moving the stored broadcast
|
|
||||||
time back can be undone by the save the popup makes every ten seconds.
|
|
||||||
|
|
||||||
- 2026-10-05: The Chrome end-to-end suite covers the last of the
|
|
||||||
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
|
||||||
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) items
|
|
||||||
([#295](https://git.eeqj.de/sneak/AutistMask/issues/295)): a token added on
|
|
||||||
Add Token by its contract address is listed on the address screen;
|
|
||||||
TransactionDetail opened from the token screen leaves that screen on the
|
|
||||||
persisted navigation stack, and Back returns to it; and the token contract row
|
|
||||||
links to the explorer's token page, read off the link rather than followed.
|
|
||||||
The network stub answers `symbol()` and `name()` for the stub token, which
|
|
||||||
adding it reads.
|
|
||||||
|
|
||||||
- 2026-10-05: Each control that leads to a signature or to the private key has a
|
|
||||||
test that it refuses a defective wallet before asking for a password
|
|
||||||
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main,
|
|
||||||
address and token screens, Export Private Key, and both approval screens, as
|
|
||||||
drawn and as clicked. Send on the confirmation screen refuses it too now,
|
|
||||||
because the popup reopens onto that screen from a saved view. The comments
|
|
||||||
that said the wallet's key cannot be derived now say that
|
|
||||||
`getSignerForAddress` refuses it, and the module comment in
|
|
||||||
`src/shared/walletDefects.js` names both earlier import paths.
|
|
||||||
|
|
||||||
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
|
|
||||||
as it drives the recovery phrase screen
|
|
||||||
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
|
|
||||||
password shows the key, leaving by the settings gear empties the screen, and
|
|
||||||
leaving while the password is still being checked never puts the key on it.
|
|
||||||
The cases use the imported key wallet rather than the HD one. Leaving drops
|
|
||||||
the address the screen was showing, and an HD wallet's key cannot be derived
|
|
||||||
without it, so on an HD wallet a late decrypt fails by itself and would never
|
|
||||||
exercise the check that discards it. The screen cannot yet be opened twice in
|
|
||||||
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
|
|
||||||
so the cases reopen the popup before the second open.
|
|
||||||
|
|
||||||
- 2026-10-05: The StateRecovery screen is driven in a real browser under the
|
|
||||||
shipped CSP, in both end-to-end suites
|
|
||||||
([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record
|
|
||||||
this build cannot read opens the popup on it; its export text box holds the
|
|
||||||
record exactly as stored; a near-miss confirmation phrase erases nothing; and
|
|
||||||
the exact phrase erases the record and reloads into Welcome. The cases run
|
|
||||||
before any wallet exists: with no wallet nothing saves on a timer, so no save
|
|
||||||
can write a good record over the unreadable one, and the erase leaves the
|
|
||||||
popup on Welcome for wallet creation.
|
|
||||||
|
|
||||||
- 2026-10-05: Escaping in the popup's views follows its own rule with no
|
|
||||||
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
|
|
||||||
decimals and holder count on a token's screen, and every USD figure (the ETH
|
|
||||||
price, each total and each balance row's value), went into `innerHTML`
|
|
||||||
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
|
|
||||||
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
|
|
||||||
code points rather than UTF-16 units, so a cut never splits an emoji into a
|
|
||||||
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
|
|
||||||
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
|
|
||||||
|
|
||||||
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
|
|
||||||
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
|
|
||||||
test that turns a fixture switch on for itself alone (a held or failing gas
|
|
||||||
estimate, a seeded native transfer or receipt, a token's lying `decimals()` or
|
|
||||||
markup symbol) turns it off again in a `finally`, and the two tests that drive
|
|
||||||
the popup's own send end on the address screen whether they pass or not,
|
|
||||||
reopening the popup to leave a wait for a receipt. The lying-`decimals()` test
|
|
||||||
checks that nothing was broadcast as soon as the send ends, before it waits
|
|
||||||
for the failure screen, so a broadcast fails it in seconds rather than after a
|
|
||||||
60-second wait. The fixture's `decimals()` override tells 0 from no override,
|
|
||||||
so a token with no decimal places can be fixtured.
|
|
||||||
|
|
||||||
- 2026-10-05: Chrome draws the popup in its monospace font
|
|
||||||
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does.
|
|
||||||
Chrome adds a stylesheet of its own to extension pages that sets the font on
|
|
||||||
`body`, and it beat Tailwind's `font-mono`: Tailwind 4 puts its classes in a
|
|
||||||
cascade layer, and a rule outside any layer wins over them. `body` now carries
|
|
||||||
`font-mono!`, which marks the class important. Both end-to-end suites check
|
|
||||||
the popup's font. The same stylesheet also makes Chrome draw the popup's text
|
|
||||||
at 12px rather than the 14px `text-sm` asks for; that is unchanged, and filed
|
|
||||||
as [#456](https://git.eeqj.de/sneak/AutistMask/issues/456).
|
|
||||||
|
|
||||||
- 2026-10-05: Dead code removed and copied view helpers shared
|
|
||||||
([#168](https://git.eeqj.de/sneak/AutistMask/issues/168)). AddressDetail and
|
|
||||||
AddressToken each defined their own `isoDate()` and `timeAgo()` in place of
|
|
||||||
the ones in `src/popup/views/helpers.js`, so a fix to the shared pair would
|
|
||||||
not have reached them. The copies were identical; every screen now uses the
|
|
||||||
shared pair. `blockieHtml()` and `tokenLabel()`, each defined twice, live in
|
|
||||||
`helpers.js` too. Removed as never called: `explorerLink()` (the views build
|
|
||||||
explorer links with `explorerUrl()`), `ETHEREUM_SEPOLIA_CHAIN_ID` (the chain
|
|
||||||
id lives in `src/shared/networks.js`), and `getWalletValue()` and
|
|
||||||
`getTotalValue()`: Home's "Total:" is the active address's total, as
|
|
||||||
`README.md` says. `addressColor()` and `etherscanAddressUrl()` are no longer
|
|
||||||
exported. Nothing the user sees changed.
|
|
||||||
|
|
||||||
- 2026-10-05: A prompt raised while another approval window has focus opens a
|
|
||||||
window of its own ([#290](https://git.eeqj.de/sneak/AutistMask/issues/290)).
|
|
||||||
The background centred each approval window on the last focused window, which
|
|
||||||
could be an earlier approval window still open; headless Chrome reports one as
|
|
||||||
1280x720, so the new window came out where the browser refused to create it,
|
|
||||||
and the request failed with no window at all. It now centres only on a browser
|
|
||||||
window, and when the browser refuses the position it asks again without one
|
|
||||||
and lets the browser place the window. In the Chrome end-to-end suite a test
|
|
||||||
could raise its prompt while the previous test's window was still closing, and
|
|
||||||
then either hit that refusal or take the closing window for its own. After a
|
|
||||||
test that passed, the runner now waits a few seconds for approval windows to
|
|
||||||
close and fails the test if one is still open; after a test that failed, it
|
|
||||||
closes them.
|
|
||||||
|
|
||||||
- 2026-10-05: The Send screen has a "Max" button
|
|
||||||
([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH
|
|
||||||
address took guessing an amount and being refused by the confirmation screen's
|
|
||||||
balance check. Max fills in a token's whole balance, cut to the 18 decimal
|
|
||||||
places the confirmation screen accepts, or for ETH the exact balance minus the
|
|
||||||
fee reserve that check gates on, never the four-decimal balance shown; a fee
|
|
||||||
estimate that finishes after the Send screen was left, or its address, holding
|
|
||||||
or recipient changed, fills nothing in. The confirmation screen works a max
|
|
||||||
ETH amount out again from its own fee estimate and signs it with that
|
|
||||||
estimate's fee fields: fetched again at signing, a fee that had risen since
|
|
||||||
would leave amount plus fee above the balance, and the node would refuse the
|
|
||||||
send. A token's maximum is still refused when ETH cannot pay the fee. Where
|
|
||||||
there is nothing to fill in, a flash message says why.
|
|
||||||
|
|
||||||
- 2026-10-05: A token scale of zero decimals is tested
|
|
||||||
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
|
|
||||||
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
|
|
||||||
from a tracked token, but no test said so: turning either of its `d !== null`
|
|
||||||
checks into a plain truthiness check left every test green while a
|
|
||||||
zero-decimal token fell through to the next source, or to "decimals unknown".
|
|
||||||
The approval tests now assert a scale of 0 from each of those two sources,
|
|
||||||
both where it is resolved and on the approval screen's Amount line. The second
|
|
||||||
half of the issue, one shared `toDecimals()`, had already landed with
|
|
||||||
[#349](https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
||||||
|
|
||||||
- 2026-10-05: The e2e suite waits for a save to land before it closes the popup
|
|
||||||
([#446](https://git.eeqj.de/sneak/AutistMask/issues/446)). The Settings round
|
|
||||||
trip switched the theme and the network and closed the popup at once, and a
|
|
||||||
close before the save lands loses the switch; with the network left on
|
|
||||||
Sepolia, a dozen later tests failed too. Each Settings switch and spam-filter
|
|
||||||
toggle is now waited for in storage before the close, and `reopenPopup()`
|
|
||||||
waits until the view it expects to reopen on is the saved one. The restore
|
|
||||||
half of the round trip and the second filter toggle change a setting right
|
|
||||||
after a reopen, while the reopened popup's own saves may still be running;
|
|
||||||
they rely on the fix for
|
|
||||||
[#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
|
|
||||||
|
|
||||||
- 2026-10-05: A change made while an earlier save from the same page is still
|
|
||||||
running is stored ([#448](https://git.eeqj.de/sneak/AutistMask/issues/448)).
|
|
||||||
`saveStateOnce()` took its baseline from the page's state after the write, so
|
|
||||||
a change made while the save waited on storage counted as already stored and
|
|
||||||
the save queued after it wrote nothing. A setting changed during the read was
|
|
||||||
lost; so was a wallet added, a site revoked or an endpoint changed during the
|
|
||||||
write, and a wallet deleted then stayed in storage. The save now copies the
|
|
||||||
page's fields when it starts, writes from that copy, and keeps the copy as the
|
|
||||||
baseline.
|
|
||||||
|
|
||||||
- 2026-10-05: The extension no longer opens a window for a site-connection
|
|
||||||
prompt already answered
|
|
||||||
([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was
|
|
||||||
decided before the toolbar popup raised for it had loaded, that popup was torn
|
|
||||||
down, `chrome.action.openPopup()` rejected, and the background opened its
|
|
||||||
fallback window for the answered approval and then removed it. In the Chrome
|
|
||||||
end-to-end suite the next test could take that window for its own prompt and
|
|
||||||
lose it under its wait. `openApprovalWindow()` now opens nothing for an
|
|
||||||
approval that is no longer pending. The blocklist test's Reject, whose window
|
|
||||||
closes itself, is clicked as the other site Reject is, with the click
|
|
||||||
witnessed. Making `e2e-chrome` a required check is still blocked: other
|
|
||||||
reports of the Chrome suite failing under load are open, among them
|
|
||||||
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290) and
|
|
||||||
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), as `README.md` says.
|
|
||||||
|
|
||||||
- 2026-10-05: The lost-password delete confirmation refuses an empty field and
|
|
||||||
ignores characters that paint nothing
|
|
||||||
([#336](https://git.eeqj.de/sneak/AutistMask/issues/336)). A wallet named only
|
|
||||||
with spaces compared equal to an empty field, so typing nothing would have
|
|
||||||
deleted it, and a zero-width space in a name made the name impossible to type
|
|
||||||
back. An empty field is now refused whatever the name is, the same invisible
|
|
||||||
characters `src/shared/symbolSpoof.js` strips are removed from both sides, and
|
|
||||||
a name that shows nothing is shown and typed back as "Wallet N".
|
|
||||||
|
|
||||||
- 2026-10-05: A `holders_count` that is not a whole number in plain digits is
|
|
||||||
unknown, not read in part
|
|
||||||
([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read
|
|
||||||
`1,000` as 1, `0x10` as 0 and `1e3` as 1, a reported low count that hides the
|
|
||||||
token in the transaction history and the send-screen token selector. A count
|
|
||||||
above `Number.MAX_SAFE_INTEGER` is unknown too, not rounded or `Infinity`. The
|
|
||||||
balance list's `holders !== null` check, which did nothing, is dropped.
|
|
||||||
`README.md` and `docs/README.md` now say how each filter treats an unknown
|
|
||||||
count and that the token screen leaves out its "Holders:" row then, and
|
|
||||||
`README.md` lists `src/shared/holders.js`.
|
|
||||||
|
|
||||||
- 2026-10-04: A popup boot in the tests loads transactions without failing
|
|
||||||
([#429](https://git.eeqj.de/sneak/AutistMask/issues/429)). The stand-in for
|
|
||||||
`filterTransactions` in `tests/support/popupBoot.js` returned a bare list,
|
|
||||||
while the real one returns `{ transactions, newFraudContracts }`, so every
|
|
||||||
boot onto Home, AddressDetail or AddressToken failed inside its transaction
|
|
||||||
loading and logged `loadHomeTxs failed` or `loadTransactions failed`; the rest
|
|
||||||
of that code never ran. The stand-in now returns the real shape, and
|
|
||||||
`tests/persistedFieldContract.test.js` boots onto each of the three and
|
|
||||||
asserts neither message is logged. `make test` time did not change measurably.
|
|
||||||
|
|
||||||
- 2026-10-04: The native token's label follows the network
|
|
||||||
([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives
|
|
||||||
each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`,
|
|
||||||
so on Sepolia the balance, the value and the fee all read `ETH`. Every native
|
|
||||||
figure now reads `nativeCurrency`, which is `ETH` on mainnet and `SepoliaETH`
|
|
||||||
on Sepolia: the balance lists, Send and confirmation screens and the
|
|
||||||
contract-recipient warning the active network's; the approval, wait, success,
|
|
||||||
error and transaction detail screens, the transaction history and the refusal
|
|
||||||
of a fee above the limit that of the network the transaction's chain id names.
|
|
||||||
A token claiming any network's `nativeCurrency` is dropped as a fake, as one
|
|
||||||
claiming `ETH` already was, and the transaction detail screen calls an entry a
|
|
||||||
token transfer when it has a token contract, not by its symbol.
|
|
||||||
- 2026-10-04: A popup that is already open when the stored profile becomes
|
|
||||||
unreadable moves to the recovery screen
|
|
||||||
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
|
|
||||||
the last good profile, with the "NOT SAVED" banner at most, until reopened.
|
|
||||||
Every save already ran the check the popup runs at open, so the popup finds
|
|
||||||
the record at the next navigation or ten-second refresh, whether or not the
|
|
||||||
network answers; a save that fails that check now raises the recovery screen
|
|
||||||
and stops the refresh. The screen it replaces is left as any navigation leaves
|
|
||||||
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
|
|
||||||
else in that popup can replace it, and a later save or a transaction wait that
|
|
||||||
ends does not clear an export or a typed confirmation. It is never saved as
|
|
||||||
the current view, so a popup opened after the record is erased in another
|
|
||||||
window opens normally. Any other failed save still gets the banner and leaves
|
|
||||||
the screen alone.
|
|
||||||
- 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is
|
|
||||||
decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date
|
|
||||||
reaches only to 275760-09-13, so a later deadline, such as the `uint256`
|
|
||||||
maximum, made the `Deadline` line throw, and the approval screen showed the
|
|
||||||
swap as an undecoded contract call with nothing saying why. That line now
|
|
||||||
reads `After 275760-09-13 00:00:00 (no deadline in practice)`.
|
|
||||||
- 2026-10-04: The swap decoder reads two router zeros the way the router does
|
|
||||||
([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in
|
|
||||||
`amountIn` of zero means an earlier step already sent the tokens to the pair;
|
|
||||||
`Amount` showed `0.0000` for it and now reads
|
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)`. A
|
|
||||||
`BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it
|
|
||||||
replaced the minimum an earlier swap step stated, so `Min. received` read
|
|
||||||
`None (no minimum guaranteed)`; it now sets the output side only when that
|
|
||||||
side holds no minimum at the point the check is reached. A nonzero
|
|
||||||
`minBalance` still sets the output side.
|
|
||||||
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
|
||||||
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
|
||||||
showed only the decoded text, with bidirectional and zero-width characters
|
|
||||||
acting on it, so a site could make the message read differently from what is
|
|
||||||
signed, and a message that was not hex was shown as NUL characters. The hex is
|
|
||||||
now shown as "Raw data" alongside the decoded text, the text is laid out left
|
|
||||||
to right in byte order, control characters, line and paragraph separators and
|
|
||||||
characters that paint nothing are shown as `U+XXXX` marks, and a message that
|
|
||||||
is not hex by the rule signing reads it with is shown as plain text with
|
|
||||||
"Sign" disabled, since such a message has no bytes to sign.
|
|
||||||
- 2026-10-04: A token that reports more than 80 decimal places has no known
|
|
||||||
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
|
|
||||||
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
|
|
||||||
above 80, so such a token left a swap or an ERC-20 call on the approval screen
|
|
||||||
undecoded, with nothing saying why. The check now stops at 80, and both
|
|
||||||
approval paths show the base-unit amount with the scale stated as unknown. The
|
|
||||||
balance list and the history list use the same check, so the same token no
|
|
||||||
longer stops an address's token balances from refreshing or its history from
|
|
||||||
loading.
|
|
||||||
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
|
|
||||||
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
|
|
||||||
every request's full URL and body, so an RPC endpoint with a key in its path
|
|
||||||
or query string printed that key on every request. It now logs the HTTP
|
|
||||||
method, the URL's origin and, for a JSON-RPC call, the method name. The
|
|
||||||
balance refresh and token lookup log the RPC endpoint by its origin too. A
|
|
||||||
failed RPC call's error line prints the error's short message, which names the
|
|
||||||
HTTP status, not its full message, which carries the request URL. A failed
|
|
||||||
endpoint check in settings names the endpoint by its origin, not the `fetch`
|
|
||||||
error's message, which carries the whole URL, password included, for a URL
|
|
||||||
with a user name and password. The README's DEBUG Mode Policy says what debug
|
|
||||||
mode logs.
|
|
||||||
|
|
||||||
- 2026-10-04: A site has at most one connection prompt and one signature prompt
|
|
||||||
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
|
|
||||||
`eth_requestAccounts` or `personal_sign` call opened another approval window,
|
|
||||||
so a page calling in a loop could cover the screen with identical prompts. A
|
|
||||||
further request of the same kind from a site whose prompt is still unanswered
|
|
||||||
is now refused with EIP-1193 `-32002`, the code a second transaction already
|
|
||||||
gets, and opens no window. Signing by `personal_sign`, `eth_sign` and
|
|
||||||
`eth_signTypedData_v4` counts as one kind. Other sites are not affected, and
|
|
||||||
the site may ask again once the user has answered. A connection prompt whose
|
|
||||||
toolbar popup closed before it connected, and which nothing shows any more, is
|
|
||||||
shown again when the site asks again.
|
|
||||||
|
|
||||||
- 2026-10-04: A nonce the site supplies with `eth_sendTransaction` is ignored
|
|
||||||
([#404](https://git.eeqj.de/sneak/AutistMask/issues/404)). It was passed on to
|
|
||||||
the transaction, so a site could replace one of the user's pending
|
|
||||||
transactions (same nonce, higher fee) or leave the new one stuck behind a gap,
|
|
||||||
and the approval screen showed it as a bare number. `nonce` is no longer one
|
|
||||||
of the fields taken from the request in `src/shared/approvalTx.js`, so the
|
|
||||||
transaction always gets the account's next nonce from the node, and that is
|
|
||||||
the nonce the approval screen shows and the popup signs.
|
|
||||||
|
|
||||||
- 2026-10-04: Remembered site permissions are held by full origin
|
|
||||||
([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and
|
|
||||||
`deniedSites` stored the hostname alone, so a grant to `https://dapp.example`
|
|
||||||
also authorised `http://dapp.example` and every port on that host, and the
|
|
||||||
prompts named only the hostname. Both lists now store and match the origin
|
|
||||||
(`scheme://host[:port]`), the key the connections approved without "Remember"
|
|
||||||
already used, in `src/background/index.js` and in Settings, whose site lists
|
|
||||||
and `AUTISTMASK_REMOVE_SITE` carry the origin too. The connection, transaction
|
|
||||||
and signature prompts show the origin. Entries saved by hostname before this
|
|
||||||
change are not migrated (pre-1.0): they match no site, and Settings lists them
|
|
||||||
until they are removed.
|
|
||||||
|
|
||||||
- 2026-10-04: A page's request is credited only to the site the browser says
|
|
||||||
sent it ([#407](https://git.eeqj.de/sneak/AutistMask/issues/407)). Where the
|
|
||||||
browser does not give the sender's origin (Firefox before 126), the background
|
|
||||||
used the tab's page, so a frame from another site would have been treated as
|
|
||||||
the site embedding it, and with no tab it used an origin the page wrote into
|
|
||||||
the message. It now uses the URL of the frame that sent the message, and
|
|
||||||
refuses the request with code 4100 when the browser gives neither. The content
|
|
||||||
script no longer writes an origin into the message.
|
|
||||||
|
|
||||||
- 2026-10-04: `make test` takes 8-13s on the shared build host, down from
|
|
||||||
17-25s, measured in alternating runs before and after the change
|
|
||||||
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
|
|
||||||
the tests (`tests/support/popupBoot.js`) resets jest's module registry so that
|
|
||||||
everything under `src/` loads fresh, and that also reloaded `ethers`,
|
|
||||||
`libsodium-wrappers-sumo`, `qrcode` and `ethereum-blockies-base64` every time.
|
|
||||||
Those four libraries are now loaded once per test file and handed to every
|
|
||||||
boot. No test or assertion changed.
|
|
||||||
|
|
||||||
- 2026-10-04: A token whose scale is unknown reads the same on the Send screen
|
|
||||||
as on the confirmation screen
|
|
||||||
([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses'
|
|
||||||
explorer reports disagree on a token's `decimals`, the Send screen showed the
|
|
||||||
stored figure while the confirmation screen it leads to said
|
|
||||||
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
|
|
||||||
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
|
|
||||||
its cause: for an unknown scale it says the wallet does not know how many
|
|
||||||
decimal places the token uses and that the transaction cannot be sent, instead
|
|
||||||
of asking the user to go back and try again, which cannot help. For any other
|
|
||||||
cause it is unchanged.
|
|
||||||
|
|
||||||
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
|
|
||||||
decoded on the approval screen
|
|
||||||
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
|
|
||||||
`src/shared/uniswap.js` had no arm for it, so the screen named the step and
|
|
||||||
showed no token or amount. The input side is the path's first token with
|
|
||||||
`amountInMax`, the output side the last token with `amountOut`. When the
|
|
||||||
transaction has such a step, the `Amount` figure reads `Up to <amount>`,
|
|
||||||
whichever step set it, there and on the wait, success and error screens,
|
|
||||||
except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any
|
|
||||||
amount at or above the `uint160` maximum) or `All available (V4 open delta)`.
|
|
||||||
In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side
|
|
||||||
is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise
|
|
||||||
`Token Out` and `Min. received` keep the output side's own token and figure.
|
|
||||||
V3 exact-out (`0x01`) is still not decoded.
|
|
||||||
|
|
||||||
- 2026-10-04: `make test` runs jest in three worker processes
|
|
||||||
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
|
|
||||||
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
|
|
||||||
one worker per CPU core: about 47 processes and 7-8 GiB per run on the shared
|
|
||||||
48-core build host. They now pass `--maxWorkers=3`, and the suite takes 23-29s
|
|
||||||
there: inside the 30-second cap in `script/test`, which is unchanged, but not
|
|
||||||
by much, because `tests/persistedFieldContract.test.js` alone takes most of it
|
|
||||||
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). One or two workers
|
|
||||||
went past the cap. `make check`, the pre-commit hook and `script/cibuild` all
|
|
||||||
run the suite through these scripts.
|
|
||||||
|
|
||||||
- 2026-10-04: The error container on each dApp approval screen keeps its height
|
|
||||||
when an error appears
|
|
||||||
([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
|
|
||||||
and `#approve-sign-error` reserved 20px, but their border and padding took
|
|
||||||
10px of it, so a one-line error grew them to 26px and pushed the buttons below
|
|
||||||
down 6px. They now reserve 30px. A new test in `tests/e2e/run.js` shows each
|
|
||||||
of the six password error containers on its own screen, empty and then with an
|
|
||||||
error, and fails if one changes height or the element below it moves. Some of
|
|
||||||
the longer messages these two containers can show still take two lines.
|
|
||||||
|
|
||||||
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
|
|
||||||
contract. It has no recipient." on its recipient line and in its transaction
|
|
||||||
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
|
|
||||||
wait, success and error screens, the transaction detail view and the history
|
|
||||||
rows on Home, AddressDetail and AddressToken showed a blank address there,
|
|
||||||
with a colour dot whose colour was `undefined`; the approval screen showed
|
|
||||||
"(contract creation)". A transaction with a real `to` is unchanged.
|
|
||||||
|
|
||||||
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
|
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
|
||||||
token balance or a network fee below 0.000001 as zero
|
token balance or a network fee below 0.000001 as zero
|
||||||
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
|
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
|
||||||
|
|||||||
+5
-15
@@ -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:
|
||||||
@@ -287,13 +285,11 @@ not appear and may be permanently lost.
|
|||||||
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
||||||
pages. When a site requests access to your wallet:
|
pages. When a site requests access to your wallet:
|
||||||
|
|
||||||
1. A popup appears showing the site's origin (its scheme, host and port, for
|
1. A popup appears showing the site's hostname and the address that will be
|
||||||
example `https://app.example`) and the address that will be shared.
|
shared.
|
||||||
2. Click "Allow" to connect or "Deny" to reject.
|
2. Click "Allow" to connect or "Deny" to reject.
|
||||||
3. Optionally check "Remember my choice for this site" to skip the prompt next
|
3. Optionally check "Remember my choice for this site" to skip the prompt next
|
||||||
time. The choice applies to that exact origin only: a choice remembered for
|
time.
|
||||||
`https://app.example` does not cover `http://app.example` or another port of
|
|
||||||
the same host, which ask again.
|
|
||||||
|
|
||||||
When a connected site requests a transaction, a separate approval popup appears
|
When a connected site requests a transaction, a separate approval popup appears
|
||||||
showing the transaction details (from, to, value, data, network fee, network and
|
showing the transaction details (from, to, value, data, network fee, network and
|
||||||
@@ -335,13 +331,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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit --maxWorkers=3",
|
"test": "jest --forceExit",
|
||||||
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
|
"test:verbose": "jest --forceExit --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
+5
-8
@@ -1,14 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite.
|
# script/test: run the test suite.
|
||||||
#
|
#
|
||||||
# jest runs three worker processes (package.json), not one per CPU core: on a
|
# The timeout bounds a hung suite; it is not a performance budget. On a
|
||||||
# many-core shared host one per core took gigabytes of RAM per run.
|
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
|
||||||
#
|
# the bound. Inside the image the same suite also pays a cold jest cache and
|
||||||
# The timeout bounds a hung suite; it is not a performance budget. On the busy
|
# shares the runner with the rest of the build, which is not what that budget
|
||||||
# shared build host the suite takes 8-13s with three workers, inside
|
# describes, so the Dockerfile raises the bound through
|
||||||
# REPO_POLICIES' 20s budget. Inside the image the same suite also pays a cold
|
|
||||||
# jest cache and shares the runner with the rest of the build, which is not what
|
|
||||||
# that budget describes, so the Dockerfile raises the bound through
|
|
||||||
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
||||||
# produces a red that means nothing, and teaches "just run it again".
|
# produces a red that means nothing, and teaches "just run it again".
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+66
-159
@@ -57,13 +57,9 @@ const windowsNs = windowsApi();
|
|||||||
const actionNs = actionApi();
|
const actionNs = actionApi();
|
||||||
|
|
||||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
||||||
//
|
|
||||||
// A site is its full origin (scheme://host[:port]), here and in the
|
|
||||||
// remembered allowedSites/deniedSites lists alike: a grant to
|
|
||||||
// https://dapp.example says nothing about http://dapp.example or another port.
|
|
||||||
const connectedSites = {};
|
const connectedSites = {};
|
||||||
|
|
||||||
// Pending approval requests: { id: { origin, resolve } }
|
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
|
|
||||||
// One transaction approval at a time, wallet-wide.
|
// One transaction approval at a time, wallet-wide.
|
||||||
@@ -87,8 +83,7 @@ const pendingApprovals = {};
|
|||||||
// authority on a nonce the network has not accepted, which an abandoned
|
// authority on a nonce the network has not accepted, which an abandoned
|
||||||
// approval then leaves a hole in.
|
// approval then leaves a hole in.
|
||||||
//
|
//
|
||||||
// Sign approvals do not take this slot: a signature consumes no nonce. They are
|
// Sign approvals are not gated: a signature consumes no nonce.
|
||||||
// limited per site instead; see findPendingApproval().
|
|
||||||
//
|
//
|
||||||
// The slot is null when free, and otherwise the handle of the request holding
|
// The slot is null when free, and otherwise the handle of the request holding
|
||||||
// it. Once that request has raised its approval the handle carries the
|
// it. Once that request has raised its approval the handle carries the
|
||||||
@@ -100,7 +95,7 @@ let txApprovalSlot = null;
|
|||||||
|
|
||||||
// EIP-1474 "resource unavailable": the standard code for a request that is
|
// EIP-1474 "resource unavailable": the standard code for a request that is
|
||||||
// refused because another one is already pending.
|
// refused because another one is already pending.
|
||||||
const APPROVAL_PENDING_CODE = -32002;
|
const TX_APPROVAL_PENDING_CODE = -32002;
|
||||||
|
|
||||||
// True at every moment this can be sent: the slot is taken immediately before
|
// True at every moment this can be sent: the slot is taken immediately before
|
||||||
// the transaction is populated, so the other request is either being prepared
|
// the transaction is populated, so the other request is either being prepared
|
||||||
@@ -137,22 +132,6 @@ function releaseTxApprovalSlotFor(approvalId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One site-connection approval and one sign approval per site at a time: a
|
|
||||||
// page that asks again before the user has answered is refused with the code
|
|
||||||
// above instead of opening another window, so it cannot bury the user in
|
|
||||||
// prompts. The pending approval itself holds the place, so a caller must test
|
|
||||||
// this and raise its approval with nothing awaited in between.
|
|
||||||
function findPendingApproval(origin, type) {
|
|
||||||
return Object.values(pendingApprovals).find(
|
|
||||||
(approval) => approval.origin === origin && approval.type === type,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const APPROVAL_PENDING_MESSAGE =
|
|
||||||
"AutistMask is already waiting for your answer to a request of this kind" +
|
|
||||||
" from this site, so this one was not shown. Please answer that one," +
|
|
||||||
" then send this one again.";
|
|
||||||
|
|
||||||
// Nonces this worker has already handed to the node, per chain and address.
|
// Nonces this worker has already handed to the node, per chain and address.
|
||||||
// This is the wallet's own knowledge that a nonce is spent, and it is checked
|
// This is the wallet's own knowledge that a nonce is spent, and it is checked
|
||||||
// before a broadcast rather than after: a node's pending count can lag a
|
// before a broadcast rather than after: a node's pending count can lag a
|
||||||
@@ -305,12 +284,7 @@ async function proxyRpc(method, params) {
|
|||||||
return json.result;
|
return json.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The site-connection approval the toolbar popup is set to open, or null while
|
|
||||||
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
|
|
||||||
let toolbarPopupApprovalId = null;
|
|
||||||
|
|
||||||
function resetPopupUrl() {
|
function resetPopupUrl() {
|
||||||
toolbarPopupApprovalId = null;
|
|
||||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||||
actionNs.setPopup({ popup: "src/popup/index.html" });
|
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||||
}
|
}
|
||||||
@@ -413,7 +387,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 +411,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 +420,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) {
|
||||||
@@ -508,23 +459,12 @@ async function openApprovalWindow(id) {
|
|||||||
|
|
||||||
// Open an approval popup and return a promise that resolves with the user decision.
|
// Open an approval popup and return a promise that resolves with the user decision.
|
||||||
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
||||||
function requestApproval(origin) {
|
function requestApproval(origin, hostname) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = { id, origin, resolve, type: "site" };
|
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||||
|
|
||||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||||
showInToolbarPopup(id);
|
|
||||||
} else {
|
|
||||||
openApprovalWindow(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show a site-connection approval in the toolbar popup, or in a separate popup
|
|
||||||
// window when the browser will not open the toolbar popup.
|
|
||||||
function showInToolbarPopup(id) {
|
|
||||||
toolbarPopupApprovalId = id;
|
|
||||||
actionNs.setPopup({
|
actionNs.setPopup({
|
||||||
popup: "src/popup/index.html?approval=" + id,
|
popup: "src/popup/index.html?approval=" + id,
|
||||||
});
|
});
|
||||||
@@ -536,6 +476,10 @@ function showInToolbarPopup(id) {
|
|||||||
} catch {
|
} catch {
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
openApprovalWindow(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
||||||
@@ -551,12 +495,13 @@ function showInToolbarPopup(id) {
|
|||||||
// screen never named.
|
// screen never named.
|
||||||
// `slot` is the transaction-approval slot its caller holds. Handing the
|
// `slot` is the transaction-approval slot its caller holds. Handing the
|
||||||
// approval's id to it is what makes retiring the approval free the slot.
|
// approval's id to it is what makes retiring the approval free the slot.
|
||||||
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
@@ -572,12 +517,13 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
|||||||
// Uses windows.create() directly because sign approvals are triggered programmatically
|
// Uses windows.create() directly because sign approvals are triggered programmatically
|
||||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||||
// unreliable in this context.
|
// unreliable in this context.
|
||||||
function requestSignApproval(origin, signParams, approvedFrom) {
|
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
@@ -655,11 +601,11 @@ runtime.onConnect.addListener((port) => {
|
|||||||
// in the worker — a balance refresh in flight, another site's approval — has
|
// in the worker — a balance refresh in flight, another site's approval — has
|
||||||
// gone on running the whole time. Loading here used to replace the very
|
// gone on running the whole time. Loading here used to replace the very
|
||||||
// objects that work was holding.
|
// objects that work was holding.
|
||||||
async function rememberSiteChoice(field, address, origin) {
|
async function rememberSiteChoice(field, address, hostname) {
|
||||||
await updateState((s) => {
|
await updateState((s) => {
|
||||||
if (!s[field][address]) s[field][address] = [];
|
if (!s[field][address]) s[field][address] = [];
|
||||||
if (!s[field][address].includes(origin)) {
|
if (!s[field][address].includes(hostname)) {
|
||||||
s[field][address].push(origin);
|
s[field][address].push(hostname);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -672,11 +618,12 @@ async function handleConnectionRequest(origin) {
|
|||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const denied = s.deniedSites[activeAddress] || [];
|
const denied = s.deniedSites[activeAddress] || [];
|
||||||
|
|
||||||
// Check denied list
|
// Check denied list
|
||||||
if (denied.includes(origin)) {
|
if (denied.includes(hostname)) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: 4001,
|
code: 4001,
|
||||||
@@ -687,50 +634,25 @@ async function handleConnectionRequest(origin) {
|
|||||||
|
|
||||||
// Check allowed list or in-memory connected
|
// Check allowed list or in-memory connected
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = findPendingApproval(origin, "site");
|
|
||||||
if (pending) {
|
|
||||||
// A toolbar popup that closed before it connected leaves its prompt
|
|
||||||
// pending, and once the toolbar popup is set to open something else
|
|
||||||
// nothing shows that prompt: the site would be refused until the
|
|
||||||
// address changed. Show it again. A prompt in a window or in a
|
|
||||||
// connected popup is settled when that closes, and one the toolbar
|
|
||||||
// popup is still set to open is a click away, so those are left alone.
|
|
||||||
if (
|
|
||||||
actionNs &&
|
|
||||||
typeof actionNs.openPopup === "function" &&
|
|
||||||
!pending.windowId &&
|
|
||||||
!pending.portConnected &&
|
|
||||||
toolbarPopupApprovalId !== pending.id
|
|
||||||
) {
|
|
||||||
showInToolbarPopup(pending.id);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open approval popup
|
// Open approval popup
|
||||||
const decision = await requestApproval(origin);
|
const decision = await requestApproval(origin, hostname);
|
||||||
|
|
||||||
if (decision.approved) {
|
if (decision.approved) {
|
||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
||||||
} else {
|
} else {
|
||||||
connectedSites[origin + ":" + activeAddress] = true;
|
connectedSites[origin + ":" + activeAddress] = true;
|
||||||
}
|
}
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
} else {
|
} else {
|
||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("deniedSites", activeAddress, origin);
|
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
@@ -776,9 +698,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) return { result: [] };
|
if (!activeAddress) return { result: [] };
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
@@ -808,9 +731,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
// [TESTNET] banner under a user who believed they were on Sepolia.
|
// [TESTNET] banner under a user who believed they were on Sepolia.
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -882,9 +806,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (method === "wallet_getPermissions") {
|
if (method === "wallet_getPermissions") {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const isConnected =
|
const isConnected =
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress];
|
connectedSites[origin + ":" + activeAddress];
|
||||||
if (!isConnected || !activeAddress) {
|
if (!isConnected || !activeAddress) {
|
||||||
return { result: [] };
|
return { result: [] };
|
||||||
@@ -910,9 +835,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -942,16 +868,9 @@ async function handleRpc(method, params, origin) {
|
|||||||
"Only proceed if you fully understand what you are signing.";
|
"Only proceed if you fully understand what you are signing.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (findPendingApproval(origin, "sign")) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const decision = await requestSignApproval(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
);
|
);
|
||||||
@@ -965,9 +884,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -983,16 +903,9 @@ async function handleRpc(method, params, origin) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (findPendingApproval(origin, "sign")) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const decision = await requestSignApproval(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
);
|
);
|
||||||
@@ -1033,9 +946,10 @@ async function handleSendTransaction(params, origin) {
|
|||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -1062,7 +976,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
if (!slot) {
|
if (!slot) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: APPROVAL_PENDING_CODE,
|
code: TX_APPROVAL_PENDING_CODE,
|
||||||
message: TX_APPROVAL_PENDING_MESSAGE,
|
message: TX_APPROVAL_PENDING_MESSAGE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1109,6 +1023,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
|
|
||||||
const decision = await requestTxApproval(
|
const decision = await requestTxApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
slot,
|
slot,
|
||||||
@@ -1182,9 +1097,10 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const hasPermission =
|
const hasPermission =
|
||||||
activeAddress &&
|
activeAddress &&
|
||||||
(allowed.includes(origin) ||
|
(allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]);
|
connectedSites[origin + ":" + activeAddress]);
|
||||||
// Same as chainChanged above: a tab without our content script
|
// Same as chainChanged above: a tab without our content script
|
||||||
// rejects, and that is expected rather than a fault.
|
// rejects, and that is expected rather than a fault.
|
||||||
@@ -1197,7 +1113,7 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tell every open tab of a site Settings removed that it has no account.
|
// Tell every open tab of a site Settings removed that it has no account.
|
||||||
async function broadcastSiteRemoved(origin) {
|
async function broadcastSiteRemoved(hostname) {
|
||||||
let tabs;
|
let tabs;
|
||||||
try {
|
try {
|
||||||
tabs = await tabsQuery({});
|
tabs = await tabsQuery({});
|
||||||
@@ -1205,7 +1121,7 @@ async function broadcastSiteRemoved(origin) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
if (!tab.url || new URL(tab.url).origin !== origin) continue;
|
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
|
||||||
tabsSendMessage(tab.id, {
|
tabsSendMessage(tab.id, {
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
eventName: "accountsChanged",
|
eventName: "accountsChanged",
|
||||||
@@ -1372,29 +1288,18 @@ if (windowsNs && windowsNs.onRemoved) {
|
|||||||
// Listen for messages from content scripts and popup
|
// Listen for messages from content scripts and popup
|
||||||
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
if (msg.type === "AUTISTMASK_RPC") {
|
if (msg.type === "AUTISTMASK_RPC") {
|
||||||
// The origin is the one the browser reports for the sender, never one
|
// Derive origin from trusted sender info to prevent origin spoofing.
|
||||||
// the message carries. Firefox before 126 gives no sender.origin, so
|
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
|
||||||
// the origin of sender.url is used: the frame that sent the message,
|
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
|
||||||
// not the tab's page, which may be another site embedding that
|
if (sender.origin) {
|
||||||
// frame. With neither, the request is refused.
|
trustedOrigin = sender.origin;
|
||||||
let trustedOrigin = sender.origin;
|
} else if (sender.tab && sender.tab.url) {
|
||||||
if (!trustedOrigin && sender.url) {
|
|
||||||
try {
|
try {
|
||||||
trustedOrigin = new URL(sender.url).origin;
|
trustedOrigin = new URL(sender.tab.url).origin;
|
||||||
} catch {
|
} catch {
|
||||||
// an unparseable URL leaves the origin unknown
|
// keep fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!trustedOrigin) {
|
|
||||||
sendResponse({
|
|
||||||
error: {
|
|
||||||
code: 4100,
|
|
||||||
message:
|
|
||||||
"The wallet could not tell which site sent this request.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
handleRpc(msg.method, msg.params, trustedOrigin)
|
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
sendResponse(response);
|
sendResponse(response);
|
||||||
@@ -1432,7 +1337,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
const resp = { origin: approval.origin };
|
const resp = {
|
||||||
|
hostname: approval.hostname,
|
||||||
|
origin: approval.origin,
|
||||||
|
};
|
||||||
if (approval.type === "tx") {
|
if (approval.type === "tx") {
|
||||||
resp.type = "tx";
|
resp.type = "tx";
|
||||||
// The populated transaction, and the address it was raised
|
// The populated transaction, and the address it was raised
|
||||||
@@ -1447,9 +1355,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
resp.approvedFrom = approval.approvedFrom;
|
resp.approvedFrom = approval.approvedFrom;
|
||||||
}
|
}
|
||||||
// Flag if the requesting domain is on the phishing blocklist.
|
// Flag if the requesting domain is on the phishing blocklist.
|
||||||
resp.isPhishingDomain = isPhishingDomain(
|
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
|
||||||
extractHostname(approval.origin),
|
|
||||||
);
|
|
||||||
sendResponse(resp);
|
sendResponse(resp);
|
||||||
} else {
|
} else {
|
||||||
sendResponse(null);
|
sendResponse(null);
|
||||||
@@ -1783,22 +1689,23 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
||||||
sendResponse(
|
sendResponse(
|
||||||
Object.keys(connectedSites).map((key) =>
|
Object.keys(connectedSites).map((key) =>
|
||||||
key.slice(0, key.lastIndexOf(":")),
|
extractHostname(key.slice(0, key.lastIndexOf(":"))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings removed this site (msg.origin) and has already dropped its
|
// Settings removed this site and has already dropped its remembered
|
||||||
// remembered entries. Its connections approved without "Remember" end
|
// entries. Its connections approved without "Remember" end here, under
|
||||||
// here, under every address, and its open tabs are told it has no account.
|
// every address, and its open tabs are told it has no account.
|
||||||
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
||||||
for (const key of Object.keys(connectedSites)) {
|
for (const key of Object.keys(connectedSites)) {
|
||||||
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
|
const origin = key.slice(0, key.lastIndexOf(":"));
|
||||||
|
if (extractHostname(origin) === msg.hostname) {
|
||||||
delete connectedSites[key];
|
delete connectedSites[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
broadcastSiteRemoved(msg.origin);
|
broadcastSiteRemoved(msg.hostname);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ window.addEventListener("message", (event) => {
|
|||||||
id,
|
id,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
|
origin: location.origin,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
+104
-64
@@ -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"> </div>
|
<div class="py-1" style="margin: 0 -0.5rem"> </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 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
placeholder="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,27 +679,34 @@
|
|||||||
</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"
|
||||||
<!-- Its sentence names why the fee could not be estimated,
|
>
|
||||||
so show() in confirmTx.js sets it. -->
|
You do not have enough ETH to pay the network fee for this
|
||||||
|
transfer. Please add ETH to this address and try again.
|
||||||
|
</div>
|
||||||
<div
|
<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"
|
||||||
></div>
|
style="visibility: hidden"
|
||||||
|
>
|
||||||
|
The network fee could not be estimated, so this transaction
|
||||||
|
cannot be checked against your balance. Please go back and
|
||||||
|
try again.
|
||||||
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -691,7 +717,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 +833,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 +862,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 +1049,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 +1127,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 +1156,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 +1230,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 +1286,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 +1316,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 +1399,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"
|
||||||
@@ -1527,7 +1563,7 @@
|
|||||||
with extreme caution.
|
with extreme caution.
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<span id="approve-tx-origin" class="font-bold"></span>
|
<span id="approve-tx-hostname" class="font-bold"></span>
|
||||||
wants to send a transaction.
|
wants to send a transaction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1597,7 +1633,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.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -1627,13 +1664,21 @@
|
|||||||
funds. Proceed with extreme caution.
|
funds. Proceed with extreme caution.
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<span id="approve-sign-origin" class="font-bold"></span>
|
<span id="approve-sign-hostname" class="font-bold"></span>
|
||||||
wants you to sign a message.
|
wants you to sign a message.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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,15 +1695,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"
|
||||||
></div>
|
style="max-height: 12rem; overflow-y: auto"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="approve-sign-hex-section" class="mb-3 hidden">
|
|
||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
|
||||||
<div
|
|
||||||
id="approve-sign-hex"
|
|
||||||
class="text-xs break-all max-h-24 overflow-y-auto"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1672,7 +1710,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.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -1703,7 +1742,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<span id="approve-origin" class="font-bold"></span>
|
<span id="approve-hostname" class="font-bold"></span>
|
||||||
wants to connect to your wallet.
|
wants to connect to your wallet.
|
||||||
</p>
|
</p>
|
||||||
<div class="text-xs text-muted mb-1">
|
<div class="text-xs text-muted mb-1">
|
||||||
@@ -1796,7 +1835,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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,13 +64,3 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A personal message on the signature screen is laid out left to right in
|
|
||||||
* the order of its bytes. Without this, right-to-left characters in it move
|
|
||||||
* the characters around them: `5`, U+05C3, `00` would read as `500`
|
|
||||||
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
|
|
||||||
* the text after it, so src/popup/views/approval.js shows one as a mark. */
|
|
||||||
.am-byte-order {
|
|
||||||
direction: ltr;
|
|
||||||
unicode-bidi: bidi-override;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
txCounterpartyHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -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) || " ";
|
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||||
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,
|
||||||
@@ -180,13 +233,16 @@ function renderTransactions(txs) {
|
|||||||
// is shown whole; the title or ENS name, where there is one, names
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
// 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 addrStr = escapeHtml(counterparty);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
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 += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -259,10 +315,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);
|
||||||
|
|||||||
@@ -6,21 +6,18 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
txCounterpartyHtml,
|
addressDotHtml,
|
||||||
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) || " ";
|
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -257,13 +309,16 @@ function renderTransactions(txs) {
|
|||||||
// is shown whole; the title or ENS name, where there is one, names
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
// 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 addrStr = escapeHtml(counterparty);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
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 += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-86
@@ -1,7 +1,6 @@
|
|||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
showView,
|
showView,
|
||||||
showError,
|
showError,
|
||||||
@@ -10,13 +9,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,
|
||||||
@@ -30,7 +25,6 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
resolveTokenSymbol,
|
||||||
@@ -74,6 +68,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 +217,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 +262,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
|
||||||
@@ -305,7 +305,7 @@ function showTxApproval(details) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
$("approve-tx-origin").textContent = details.origin;
|
$("approve-tx-hostname").textContent = details.hostname;
|
||||||
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
||||||
|
|
||||||
// Show token symbol next to contract address if known
|
// Show token symbol next to contract address if known
|
||||||
@@ -318,7 +318,7 @@ function showTxApproval(details) {
|
|||||||
toHtml += approvalAddressHtml(toAddr);
|
toHtml += approvalAddressHtml(toAddr);
|
||||||
$("approve-tx-to").innerHTML = toHtml;
|
$("approve-tx-to").innerHTML = toHtml;
|
||||||
} else {
|
} else {
|
||||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ethValueFormatted = formatTxValue(
|
const ethValueFormatted = formatTxValue(
|
||||||
@@ -327,15 +327,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);
|
||||||
|
|
||||||
@@ -386,48 +379,20 @@ function showTxApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a personal message is hex by the rule signing reads it with:
|
|
||||||
// signing takes getBytes(message), which throws on anything else.
|
|
||||||
function isHexMessage(message) {
|
|
||||||
try {
|
|
||||||
getBytes(message);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text the hex message's bytes decode to as UTF-8, or null when they are
|
|
||||||
// not UTF-8. The caller has checked that the message is hex.
|
|
||||||
function decodeHexMessage(hex) {
|
function decodeHexMessage(hex) {
|
||||||
try {
|
try {
|
||||||
return toUtf8String(getBytes(hex));
|
const bytes = Uint8Array.from(
|
||||||
|
hex
|
||||||
|
.slice(2)
|
||||||
|
.match(/.{1,2}/g)
|
||||||
|
.map((b) => parseInt(b, 16)),
|
||||||
|
);
|
||||||
|
return toUtf8String(bytes);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A character shown as a bordered U+XXXX mark.
|
|
||||||
function codePointMark(c) {
|
|
||||||
const code = c.codePointAt(0).toString(16).toUpperCase();
|
|
||||||
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text as HTML, with each character that paints nothing (zero-width and
|
|
||||||
// bidirectional characters, variation selectors and Hangul fillers among
|
|
||||||
// them), each control character and each line or paragraph separator
|
|
||||||
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order layout
|
|
||||||
// for everything after it. The marks are plain ASCII, so the second pass
|
|
||||||
// leaves them be.
|
|
||||||
function markInvisibleCharacters(text) {
|
|
||||||
return escapeHtml(text)
|
|
||||||
.replace(INVISIBLE_CHARACTERS, codePointMark)
|
|
||||||
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
|
|
||||||
c === "\n" ? "<br>" : codePointMark(c),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The type ethers will sign typed data as. ethers does not read the page's
|
// The type ethers will sign typed data as. ethers does not read the page's
|
||||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||||
// refers to. Throws when the types name no such single struct, which ethers
|
// refers to. Throws when the types name no such single struct, which ethers
|
||||||
@@ -679,7 +644,7 @@ function showSignApproval(details) {
|
|||||||
pendingSignParams = sp;
|
pendingSignParams = sp;
|
||||||
pendingSignFrom = details.approvedFrom;
|
pendingSignFrom = details.approvedFrom;
|
||||||
|
|
||||||
$("approve-sign-origin").textContent = details.origin;
|
$("approve-sign-hostname").textContent = details.hostname;
|
||||||
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
||||||
details.approvedFrom,
|
details.approvedFrom,
|
||||||
);
|
);
|
||||||
@@ -691,33 +656,15 @@ function showSignApproval(details) {
|
|||||||
? "Typed data (EIP-712)"
|
? "Typed data (EIP-712)"
|
||||||
: "Personal message";
|
: "Personal message";
|
||||||
|
|
||||||
// A personal message is signed as the bytes its hex encodes, so the hex
|
|
||||||
// is shown as well as any text it decodes to, and that text is laid out
|
|
||||||
// left to right in the order of its bytes. Signing reads the bytes from
|
|
||||||
// the hex, so a message that is not hex cannot be signed: it is shown as
|
|
||||||
// the text it is, and refused.
|
|
||||||
let refusal = null;
|
|
||||||
$("approve-sign-hex-section").classList.add("hidden");
|
|
||||||
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
|
|
||||||
if (isTyped) {
|
if (isTyped) {
|
||||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||||
refusal = typedDataRefusal(sp);
|
} else {
|
||||||
} else if (isHexMessage(sp.message)) {
|
|
||||||
const decoded = decodeHexMessage(sp.message);
|
const decoded = decodeHexMessage(sp.message);
|
||||||
if (decoded !== null) {
|
if (decoded !== null) {
|
||||||
$("approve-sign-message").innerHTML =
|
$("approve-sign-message").textContent = decoded;
|
||||||
markInvisibleCharacters(decoded);
|
|
||||||
} else {
|
} else {
|
||||||
$("approve-sign-message").textContent = "This message is not text.";
|
$("approve-sign-message").textContent = sp.message;
|
||||||
}
|
}
|
||||||
$("approve-sign-hex").textContent = sp.message;
|
|
||||||
$("approve-sign-hex-section").classList.remove("hidden");
|
|
||||||
} else {
|
|
||||||
$("approve-sign-message").innerHTML = markInvisibleCharacters(
|
|
||||||
sp.message,
|
|
||||||
);
|
|
||||||
refusal =
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display danger warning for eth_sign (raw hash signing)
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
@@ -739,6 +686,7 @@ function showSignApproval(details) {
|
|||||||
|
|
||||||
showView("approve-sign");
|
showView("approve-sign");
|
||||||
attachCopyHandlers("view-approve-sign");
|
attachCopyHandlers("view-approve-sign");
|
||||||
|
const refusal = typedDataRefusal(sp);
|
||||||
if (refusal) {
|
if (refusal) {
|
||||||
showError("approve-sign-error", refusal);
|
showError("approve-sign-error", refusal);
|
||||||
$("btn-approve-sign").disabled = true;
|
$("btn-approve-sign").disabled = true;
|
||||||
@@ -783,7 +731,7 @@ async function show(id) {
|
|||||||
"approve-site-phishing-warning",
|
"approve-site-phishing-warning",
|
||||||
details.isPhishingDomain,
|
details.isPhishingDomain,
|
||||||
);
|
);
|
||||||
$("approve-origin").textContent = details.origin;
|
$("approve-hostname").textContent = details.hostname;
|
||||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||||
attachCopyHandlers("view-approve-site");
|
attachCopyHandlers("view-approve-site");
|
||||||
$("approve-remember").checked = state.rememberSiteChoice;
|
$("approve-remember").checked = state.rememberSiteChoice;
|
||||||
@@ -822,10 +770,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);
|
||||||
|
|||||||
+37
-126
@@ -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,32 +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.
|
|
||||||
// Without the token's scale estimateGas() cannot encode the transfer, so
|
|
||||||
// the estimate fails every time and going back cannot help; any other
|
|
||||||
// failure may clear on a retry.
|
|
||||||
$("confirm-fee-unknown-error").textContent =
|
|
||||||
isErc20 && txInfo.tokenDecimals == null
|
|
||||||
? "The network fee could not be estimated, because this wallet" +
|
|
||||||
" does not know how many decimal places this token uses, so" +
|
|
||||||
" this transaction cannot be sent."
|
|
||||||
: "The network fee could not be estimated, so this transaction" +
|
|
||||||
" cannot be checked against your balance. Please go back and" +
|
|
||||||
" try again.";
|
|
||||||
|
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
|
|
||||||
@@ -267,9 +232,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,
|
||||||
@@ -281,8 +244,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.
|
||||||
// gas and fee-unknown ones, which show() sets.
|
|
||||||
const messages = [];
|
const messages = [];
|
||||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||||
messages.push("Please enter a valid amount to send.");
|
messages.push("Please enter a valid amount to send.");
|
||||||
@@ -311,13 +273,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 +346,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,54 +364,24 @@ 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.message);
|
||||||
if (pendingTx !== txInfo) return;
|
if (pendingTx !== txInfo) return;
|
||||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
@@ -464,19 +392,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 +465,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 +474,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 +494,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 +504,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
|
||||||
|
|||||||
@@ -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
-111
@@ -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)) || " "
|
? formatUsd(amount * price) || " "
|
||||||
: " ";
|
: " ";
|
||||||
// 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
|
||||||
@@ -482,29 +431,6 @@ function addressTitle(address, wallets) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What every recipient line and history row says for a transaction with no
|
|
||||||
// `to`. Such a transaction creates a contract, so there is no address to show,
|
|
||||||
// and a blank line on these screens reads as a rendering fault.
|
|
||||||
const CONTRACT_CREATION_TEXT =
|
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
|
||||||
|
|
||||||
// The last two lines of a transaction history row: the counterparty's colour
|
|
||||||
// dot and name beside the amount, then its full address. A contract creation
|
|
||||||
// the user sent has no counterparty (its `to` is ""), so its row has the
|
|
||||||
// amount alone and the contract creation sentence in place of the address.
|
|
||||||
function txCounterpartyHtml(address, nameHtml, amountHtml) {
|
|
||||||
if (!address) {
|
|
||||||
return (
|
|
||||||
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
|
|
||||||
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
|
|
||||||
`<div class="am-address">${escapeHtml(address)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render an address with color dot, optional ENS name, optional title,
|
// Render an address with color dot, optional ENS name, optional title,
|
||||||
// and optional truncation. Title and ENS are shown as bold labels above
|
// and optional truncation. Title and ENS are shown as bold labels above
|
||||||
// the full address.
|
// the full address.
|
||||||
@@ -513,9 +439,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 +497,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,20 +637,17 @@ module.exports = {
|
|||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
nativeCurrency,
|
|
||||||
tokenLabel,
|
|
||||||
formatFee,
|
formatFee,
|
||||||
|
addressColor,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
blockieHtml,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
txCounterpartyHtml,
|
|
||||||
formatAddressHtml,
|
formatAddressHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
copyableHtml,
|
copyableHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
|
etherscanAddressUrl,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
explorerUrl,
|
explorerUrl,
|
||||||
EXT_ICON,
|
EXT_ICON,
|
||||||
|
|||||||
+15
-24
@@ -6,21 +6,14 @@ const {
|
|||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
txCounterpartyHtml,
|
|
||||||
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 +56,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"
|
||||||
: " ";
|
: " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +67,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)) || " ";
|
||||||
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,13 +122,16 @@ function renderHomeTxList(ctx) {
|
|||||||
// names it on the line above rather than replacing it.
|
// names it on the line above rather than replacing it.
|
||||||
const title = addressTitle(counterparty, state.wallets);
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const titleStr = title ? escapeHtml(title) : "";
|
const titleStr = title ? escapeHtml(title) : "";
|
||||||
|
const addrStr = escapeHtml(counterparty);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
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 += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -189,11 +184,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 +232,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 +246,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 +272,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) || " "}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||||
html += balanceLinesForAddress(
|
html += balanceLinesForAddress(
|
||||||
addr,
|
addr,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
|||||||
+56
-171
@@ -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()),
|
||||||
);
|
);
|
||||||
@@ -163,50 +146,6 @@ function renderSendTokenSelect(addr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The token balance and scale the Send screen states and hands the
|
|
||||||
// confirmation screen, so the two screens describe the holding the same way.
|
|
||||||
//
|
|
||||||
// The scale is resolved the same way balances.js resolved the scale it
|
|
||||||
// DISPLAYED this token's balance at: bundled list, then the user's tracked
|
|
||||||
// tokens, then the explorer. The stored tokenBalances[].decimals is the
|
|
||||||
// explorer's own answer alone, so reading it raw carries a null forward for a
|
|
||||||
// token the wallet does know the scale of — and displayedDecimals() then throws
|
|
||||||
// inside estimateGas(), which the confirmation screen reports as an unestimable
|
|
||||||
// fee. Unsendable, over a scale that was never in doubt
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
|
|
||||||
// knows: no fallback.
|
|
||||||
//
|
|
||||||
// Resolved WITH `wallets`, which balances.js does not pass: that adds
|
|
||||||
// explorerDecimals()'s cross-address check, so a contract two addresses report
|
|
||||||
// different scales for answers null rather than picking one. That check has to
|
|
||||||
// apply here, because this scale encodes the transfer — it is carried forward
|
|
||||||
// so the transfer is encoded with the number the user read rather than with
|
|
||||||
// whatever the contract answers at signing time (see
|
|
||||||
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
|
||||||
// fetch time and cannot consult a state it is in the middle of replacing.
|
|
||||||
//
|
|
||||||
// The two resolutions can therefore differ, and where they do, the stored
|
|
||||||
// `balance` is a quantity computed at a scale this screen has just declined to
|
|
||||||
// stand behind. Stating it would leave validateTransfer() checking the amount
|
|
||||||
// against a number the wallet does not vouch for, so it is withdrawn: unknown
|
|
||||||
// scale means unknown balance. It is null rather than "0": both screens state
|
|
||||||
// an unknown balance as unknown, and validateTransfer() treats it as no balance
|
|
||||||
// to spend from, which is the fail-closed side of an amount nobody can check.
|
|
||||||
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
|
|
||||||
// all is an absence of holdings, which is true at every scale.
|
|
||||||
function tokenBalanceAndDecimals(addr, token) {
|
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
||||||
);
|
|
||||||
const tokenDecimals = resolveTokenDecimals(token, {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.wallets,
|
|
||||||
});
|
|
||||||
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
|
||||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
|
||||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSendBalance() {
|
function updateSendBalance() {
|
||||||
const addr = currentAddress();
|
const addr = currentAddress();
|
||||||
if (!addr) return;
|
if (!addr) return;
|
||||||
@@ -221,19 +160,20 @@ 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 tb = (addr.tokenBalances || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
const symbol = resolveSymbol(
|
const symbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
// A null balance is a holding whose scale is unknown. Saying a figure
|
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||||
// for it would be a claim about the amount, so it reads as the
|
// for it would be a claim about the amount; the send itself is
|
||||||
// confirmation screen's balance line reads it; the send itself is
|
|
||||||
// refused later by transferAmountUnits() for the same missing scale.
|
// refused later by transferAmountUnits() for the same missing scale.
|
||||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
const bal = tb ? tb.balance : "0";
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "Current balance: unknown (" + symbol + ")"
|
? "Current balance: unknown (" + symbol + ")"
|
||||||
@@ -244,102 +184,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;
|
||||||
@@ -393,17 +240,59 @@ function init(_ctx) {
|
|||||||
|
|
||||||
let tokenSymbol = null;
|
let tokenSymbol = null;
|
||||||
let tokenBalance = null;
|
let tokenBalance = null;
|
||||||
|
// The scale the amount and the balance below are rendered at, carried
|
||||||
|
// forward so the transfer is encoded with the number the user read
|
||||||
|
// rather than with whatever the contract answers at signing time. See
|
||||||
|
// src/shared/transferAmount.js.
|
||||||
let tokenDecimals = null;
|
let tokenDecimals = null;
|
||||||
if (token !== "ETH") {
|
if (token !== "ETH") {
|
||||||
|
const tb = (addr.tokenBalances || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
tokenSymbol = resolveSymbol(
|
tokenSymbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
// null carried through rather than flattened to "0": the confirm
|
||||||
addr,
|
// screen states an unknown balance as unknown, and
|
||||||
token,
|
// validateTransfer() treats it as no balance to spend from, which
|
||||||
));
|
// is the fail-closed side of an amount nobody can check.
|
||||||
|
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
||||||
|
// Resolved the same way balances.js resolved the scale it
|
||||||
|
// DISPLAYED this token's balance at: bundled list, then the user's
|
||||||
|
// tracked tokens, then the explorer. The stored
|
||||||
|
// tokenBalances[].decimals is the explorer's own answer alone, so
|
||||||
|
// reading it raw carries a null forward for a token the wallet
|
||||||
|
// does know the scale of — and displayedDecimals() then throws
|
||||||
|
// inside estimateGas(), which the confirmation screen reports as
|
||||||
|
// an unestimable fee. Unsendable, over a scale that was never in
|
||||||
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
|
// Still null when nothing knows: no fallback.
|
||||||
|
//
|
||||||
|
// Resolved WITH `wallets`, which balances.js does not pass: that
|
||||||
|
// adds explorerDecimals()'s cross-address check, so a contract two
|
||||||
|
// addresses report different scales for answers null rather than
|
||||||
|
// picking one. That check has to apply here, because this value
|
||||||
|
// encodes a transfer; balances.js is formatting one explorer row
|
||||||
|
// at fetch time and cannot consult a state it is in the middle of
|
||||||
|
// replacing.
|
||||||
|
tokenDecimals = resolveTokenDecimals(token, {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
|
// The two resolutions can therefore differ, and where they do, the
|
||||||
|
// stored `balance` is a quantity computed at a scale this screen
|
||||||
|
// has just declined to stand behind. Stating it would leave
|
||||||
|
// validateTransfer() checking the amount against a number the
|
||||||
|
// wallet does not vouch for, and — since the unknown-balance path
|
||||||
|
// is gated on the balance, not on the scale — would leave the
|
||||||
|
// fee-estimate failure as the only thing on the confirmation
|
||||||
|
// screen, which says nothing about decimals. Unknown scale means
|
||||||
|
// unknown balance. Only a stored quantity is withdrawn: the "0"
|
||||||
|
// for a token that has no row at all is an absence of holdings,
|
||||||
|
// which is true at every scale.
|
||||||
|
if (tb && tokenDecimals === null) tokenBalance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
@@ -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 = "";
|
||||||
|
|||||||
+29
-33
@@ -16,12 +16,7 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
const {
|
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||||
log,
|
|
||||||
debugFetch,
|
|
||||||
urlOrigin,
|
|
||||||
setRuntimeDebug,
|
|
||||||
} = require("../../shared/log");
|
|
||||||
const deleteWallet = require("./deleteWallet");
|
const deleteWallet = require("./deleteWallet");
|
||||||
const showPhrase = require("./showPhrase");
|
const showPhrase = require("./showPhrase");
|
||||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||||
@@ -39,35 +34,35 @@ const { notify, sendMessage } = require("../../shared/browserApi");
|
|||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
|
|
||||||
// One row per site origin, however many addresses it appears under, each with
|
// One row per hostname, however many addresses or origins it appears under,
|
||||||
// an [x] that hands it to onRemove.
|
// each with an [x] that hands it to onRemove.
|
||||||
function renderSiteList(containerId, origins, onRemove) {
|
function renderSiteList(containerId, hostnames, onRemove) {
|
||||||
const container = $(containerId);
|
const container = $(containerId);
|
||||||
const unique = [...new Set(origins)];
|
const unique = [...new Set(hostnames)];
|
||||||
if (unique.length === 0) {
|
if (unique.length === 0) {
|
||||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = "";
|
let html = "";
|
||||||
unique.forEach((origin) => {
|
unique.forEach((hostname) => {
|
||||||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||||||
// An origin the URL parser produced cannot carry a delimiter, so
|
// A hostname the URL parser produced cannot carry a delimiter, so
|
||||||
// this is escaped for the rule rather than for a known hole — the
|
// this is escaped for the rule rather than for a known hole — the
|
||||||
// rule being that nothing reaches innerHTML unescaped.
|
// rule being that nothing reaches innerHTML unescaped.
|
||||||
html += `<span>${escapeHtml(origin)}</span>`;
|
html += `<span>${escapeHtml(hostname)}</span>`;
|
||||||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
|
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
|
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop a site origin from a remembered site list under every address.
|
// Drop a hostname from a remembered site list under every address.
|
||||||
function forgetOrigin(siteMap, origin) {
|
function forgetHostname(siteMap, hostname) {
|
||||||
for (const addr of Object.keys(siteMap)) {
|
for (const addr of Object.keys(siteMap)) {
|
||||||
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
|
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
|
||||||
if (siteMap[addr].length === 0) {
|
if (siteMap[addr].length === 0) {
|
||||||
delete siteMap[addr];
|
delete siteMap[addr];
|
||||||
}
|
}
|
||||||
@@ -77,16 +72,16 @@ function forgetOrigin(siteMap, origin) {
|
|||||||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
||||||
// no longer allowed under any address, and the background ends its
|
// no longer allowed under any address, and the background ends its
|
||||||
// connections approved without "Remember" and tells its open tabs.
|
// connections approved without "Remember" and tells its open tabs.
|
||||||
async function removeAllowedSite(origin) {
|
async function removeAllowedSite(hostname) {
|
||||||
forgetOrigin(state.allowedSites, origin);
|
forgetHostname(state.allowedSites, hostname);
|
||||||
await saveState();
|
await saveState();
|
||||||
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
|
||||||
await renderSiteLists();
|
await renderSiteLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing a denied site only forgets the refusal; it connects nothing.
|
// Removing a denied site only forgets the refusal; it connects nothing.
|
||||||
async function removeDeniedSite(origin) {
|
async function removeDeniedSite(hostname) {
|
||||||
forgetOrigin(state.deniedSites, origin);
|
forgetHostname(state.deniedSites, hostname);
|
||||||
await saveState();
|
await saveState();
|
||||||
await renderSiteLists();
|
await renderSiteLists();
|
||||||
}
|
}
|
||||||
@@ -213,7 +208,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");
|
||||||
@@ -272,11 +272,8 @@ function init(ctx) {
|
|||||||
showFlash("Wrong network: expected " + net.name + ".");
|
showFlash("Wrong network: expected " + net.name + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
log.errorf("RPC validation fetch failed:", e.message);
|
||||||
// key included, in the message of the error it throws for a URL
|
|
||||||
// with a user name and password or one it cannot parse.
|
|
||||||
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -298,9 +295,8 @@ function init(ctx) {
|
|||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Not the error's message, as for the RPC check above.
|
log.errorf("Blockscout validation failed:", e.message);
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -429,7 +425,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 = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)}"` +
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
blockieHtml,
|
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
copyableHtml,
|
copyableHtml,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
@@ -22,8 +20,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 +40,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 +83,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();
|
||||||
@@ -98,18 +94,13 @@ function render() {
|
|||||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||||
|
|
||||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||||
|
const toTitle = addressTitle(tx.to, state.wallets);
|
||||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||||
tx.from,
|
tx.from,
|
||||||
tx.fromEns,
|
tx.fromEns,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
);
|
);
|
||||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
|
||||||
if (tx.to) {
|
|
||||||
const toTitle = addressTitle(tx.to, state.wallets);
|
|
||||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||||
} else {
|
|
||||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact amount (full precision, copyable)
|
// Exact amount (full precision, copyable)
|
||||||
const detailSym = displaySymbol(tx.symbol);
|
const detailSym = displaySymbol(tx.symbol);
|
||||||
@@ -182,7 +173,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 +191,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 +221,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 +281,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 +298,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;
|
||||||
|
|||||||
+21
-21
@@ -4,7 +4,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
@@ -13,10 +12,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");
|
||||||
|
|
||||||
@@ -60,10 +58,7 @@ function endWait() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
|
||||||
// writes `to: toAddr || ""`).
|
|
||||||
function toAddressHtml(address) {
|
function toAddressHtml(address) {
|
||||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
|
||||||
const title = addressTitle(address, state.wallets);
|
const title = addressTitle(address, state.wallets);
|
||||||
return renderAddressHtml(address, { title });
|
return renderAddressHtml(address, { title });
|
||||||
}
|
}
|
||||||
@@ -87,13 +82,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);
|
||||||
@@ -138,7 +129,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
// failed — which matters most on a resumed wait, where the
|
// failed — which matters most on a resumed wait, where the
|
||||||
// first poll is already past the deadline.
|
// first poll is already past the deadline.
|
||||||
answered = false;
|
answered = false;
|
||||||
log.errorf("poll receipt failed:", e.shortMessage || e.message);
|
log.errorf("poll receipt failed:", e.message);
|
||||||
}
|
}
|
||||||
// The lookup is async: the wait may have ended while it was in
|
// The lookup is async: the wait may have ended while it was in
|
||||||
// flight, in which case this result must not touch the view.
|
// flight, in which case this result must not touch the view.
|
||||||
@@ -198,10 +189,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;
|
||||||
@@ -212,9 +202,8 @@ function restoreWait() {
|
|||||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||||
// A string is the whole requirement: the empty string is what a
|
// A string is the whole requirement: the empty string is what a
|
||||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||||
// || ""`), an empty `to` renders as a contract creation and an empty
|
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||||
// amount renders harmlessly, so refusing it would abandon a wait the live
|
// would abandon a wait the live path itself created.
|
||||||
// path itself created.
|
|
||||||
if (typeof info.to !== "string") return false;
|
if (typeof info.to !== "string") return false;
|
||||||
if (typeof info.amount !== "string") return false;
|
if (typeof info.amount !== "string") return false;
|
||||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||||
@@ -229,7 +218,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 +232,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 +315,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,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("contract check failed:", e.shortMessage || e.message);
|
log.errorf("contract check failed:", e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip tx count check for contracts — they may legitimately have
|
// Skip tx count check for contracts — they may legitimately have
|
||||||
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("tx count check failed:", e.shortMessage || e.message);
|
log.errorf("tx count check failed:", e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -51,15 +51,11 @@ const POPULATE_TIMEOUT_MS = 20000;
|
|||||||
// passed to ethers: the object is page-controlled, and a future ethers that
|
// passed to ethers: the object is page-controlled, and a future ethers that
|
||||||
// learns to carry a new transaction field must not start picking one up out of
|
// learns to carry a new transaction field must not start picking one up out of
|
||||||
// it without this module knowing.
|
// it without this module knowing.
|
||||||
//
|
|
||||||
// The nonce is not taken from the page; it is always the account's next nonce
|
|
||||||
// from the network. A page that chose it could replace one of the user's
|
|
||||||
// pending transactions (the same nonce at a higher fee) or leave this one stuck
|
|
||||||
// behind a gap (a nonce above the next one).
|
|
||||||
const REQUEST_FIELDS = [
|
const REQUEST_FIELDS = [
|
||||||
"to",
|
"to",
|
||||||
"value",
|
"value",
|
||||||
"data",
|
"data",
|
||||||
|
"nonce",
|
||||||
"gasLimit",
|
"gasLimit",
|
||||||
"gasPrice",
|
"gasPrice",
|
||||||
"maxFeePerGas",
|
"maxFeePerGas",
|
||||||
|
|||||||
@@ -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.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-16
@@ -10,7 +10,7 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { ERC20_ABI } = require("./constants");
|
const { ERC20_ABI } = require("./constants");
|
||||||
const { NETWORKS } = require("./networks");
|
const { NETWORKS } = require("./networks");
|
||||||
const { log, debugFetch, urlOrigin } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { deriveAddressFromXpub } = require("./wallet");
|
const { deriveAddressFromXpub } = require("./wallet");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
@@ -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;
|
||||||
@@ -203,7 +203,7 @@ async function refreshBalances(
|
|||||||
trackedTokens,
|
trackedTokens,
|
||||||
networkId,
|
networkId,
|
||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ async function refreshBalances(
|
|||||||
log.errorf(
|
log.errorf(
|
||||||
"ENS reverse failed",
|
"ENS reverse failed",
|
||||||
addr.address,
|
addr.address,
|
||||||
e.shortMessage || e.message,
|
e.message,
|
||||||
);
|
);
|
||||||
// Keep existing addr.ensName if we had one
|
// Keep existing addr.ensName if we had one
|
||||||
}),
|
}),
|
||||||
@@ -280,7 +280,7 @@ async function refreshBalances(
|
|||||||
// Look up token metadata from its contract.
|
// Look up token metadata from its contract.
|
||||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
|
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||||
|
|
||||||
@@ -305,10 +305,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
|||||||
name = await contract.name();
|
name = await contract.name();
|
||||||
log.debugf("name() =", name);
|
log.debugf("name() =", name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warnf(
|
log.warnf("name() failed, using symbol as name:", e.message);
|
||||||
"name() failed, using symbol as name:",
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
name = symbol;
|
name = symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+1
-5
@@ -42,11 +42,7 @@ async function resolveEnsName(address, rpcUrl, networkId) {
|
|||||||
setCache(address, name);
|
setCache(address, name);
|
||||||
return name;
|
return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf(
|
log.errorf("ENS reverse lookup failed", address, e.message);
|
||||||
"ENS reverse lookup failed",
|
|
||||||
address,
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
// Don't cache failures — let subsequent lookups retry
|
// Don't cache failures — let subsequent lookups retry
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-15
@@ -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
-25
@@ -42,34 +42,14 @@ const log = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// The origin (scheme, host and port) of a URL, for logging in place of the
|
// Fetch wrapper that debug-logs every request and response.
|
||||||
// URL: RPC providers put API keys in the path or the query string, and a URL
|
|
||||||
// can carry a user name and password, which the origin leaves out. A URL that
|
|
||||||
// does not parse gives "", so logging never stops a request.
|
|
||||||
function urlOrigin(url) {
|
|
||||||
try {
|
|
||||||
return new URL(url).origin;
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch wrapper that debug-logs every request and response. It logs the
|
|
||||||
// URL's origin and, for a JSON-RPC body, the method name: never the full URL
|
|
||||||
// or body, which can carry an API key or a signed transaction.
|
|
||||||
async function debugFetch(url, opts) {
|
async function debugFetch(url, opts) {
|
||||||
const method = (opts && opts.method) || "GET";
|
const method = (opts && opts.method) || "GET";
|
||||||
const origin = urlOrigin(url);
|
const body = opts && opts.body;
|
||||||
let rpcMethod = "";
|
log.debugf("fetch →", method, url, body || "");
|
||||||
try {
|
|
||||||
rpcMethod = JSON.parse(opts.body).method || "";
|
|
||||||
} catch {
|
|
||||||
// no body, or a body that is not JSON
|
|
||||||
}
|
|
||||||
log.debugf("fetch →", method, origin, rpcMethod);
|
|
||||||
const resp = await fetch(url, opts);
|
const resp = await fetch(url, opts);
|
||||||
log.debugf("fetch ←", resp.status, origin);
|
log.debugf("fetch ←", resp.status, url);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
|
module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ function tokenRefs(value) {
|
|||||||
|
|
||||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
// A list of strings, for the fields whose entries are dereferenced as text:
|
||||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
||||||
// src/shared/transactions.js) and each address's origin list in the site maps
|
// src/shared/transactions.js) and each address's hostname list in the site maps
|
||||||
// below (`o !== origin` filters, `list.includes(origin)` in the background).
|
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
||||||
//
|
//
|
||||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
||||||
// with a malformed entry DROPPED rather than repaired. A number in an origin
|
// with a malformed entry DROPPED rather than repaired. A number in a hostname
|
||||||
// list names no site and a number in fraudContracts names no contract, so there
|
// list names no site and a number in fraudContracts names no contract, so there
|
||||||
// is nothing to repair either to, and the empty list is a legitimate value that
|
// is nothing to repair either to, and the empty list is a legitimate value that
|
||||||
// survives. The result is a fresh array of primitives, so it shares no
|
// survives. The result is a fresh array of primitives, so it shares no
|
||||||
@@ -116,22 +116,21 @@ function textList(value) {
|
|||||||
return value.filter((entry) => typeof entry === "string");
|
return value.filter((entry) => typeof entry === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
|
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
||||||
// full scheme://host[:port] of a site.
|
|
||||||
//
|
//
|
||||||
// The container check these had (truthy and not an array) is not the floor:
|
// The container check these had (truthy and not an array) is not the floor:
|
||||||
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
||||||
// level below it. saveState() merges these maps per key and then per origin
|
// level below it. saveState() merges these maps per key and then per hostname
|
||||||
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
||||||
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
||||||
// rendered, which is why every save from then on failed while the UI looked
|
// rendered, which is why every save from then on failed while the UI looked
|
||||||
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
||||||
// revoke button (`list.filter()`), and the background's
|
// revoke button (`list.filter()`), and the background's
|
||||||
// `allowed.includes(origin)` gate, dereference it the same way; on that last
|
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
|
||||||
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
||||||
// could widen a site permission rather than merely throw.
|
// could widen a site permission rather than merely throw.
|
||||||
//
|
//
|
||||||
// An address key whose value is not a list of origins is dropped entirely: it
|
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||||
// grants and denies nothing, and dropping it fails closed. A stored own
|
// grants and denies nothing, and dropping it fails closed. A stored own
|
||||||
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
||||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
// can never be a wallet address, so it grants nothing either, and keeping it
|
||||||
@@ -144,9 +143,9 @@ function siteMap(value) {
|
|||||||
if (!isRecord(value)) return out;
|
if (!isRecord(value)) return out;
|
||||||
for (const address of Object.keys(value)) {
|
for (const address of Object.keys(value)) {
|
||||||
if (address === "__proto__") continue;
|
if (address === "__proto__") continue;
|
||||||
const origins = textList(value[address]);
|
const hostnames = textList(value[address]);
|
||||||
if (origins.length === 0) continue;
|
if (hostnames.length === 0) continue;
|
||||||
defineOwn(out, address, origins);
|
defineOwn(out, address, hostnames);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-22
@@ -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
|
||||||
@@ -304,7 +304,7 @@ function mergeAddress(base, ours, theirs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
||||||
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
||||||
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
||||||
// object diff — so a key one page added or removed applies independently of
|
// object diff — so a key one page added or removed applies independently of
|
||||||
// a key another page edited. Unlike an array's identity function, an object
|
// a key another page edited. Unlike an array's identity function, an object
|
||||||
@@ -353,25 +353,25 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. The origin
|
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||||
// list is itself membership, not a leaf — the background appends a newly
|
// list is itself membership, not a leaf — the background appends a newly
|
||||||
// approved/denied origin to it, and the Settings "revoke" button
|
// approved/denied hostname to it, and the Settings "revoke" button
|
||||||
// (src/popup/views/settings.js) filters an origin out of it in place, from a
|
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
|
||||||
// different page. Merge it the same way wallets are merged: identity is the
|
// different page. Merge it the same way wallets are merged: identity is the
|
||||||
// origin itself, so a merged pair is always equal and mergeItem is a no-op
|
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
|
||||||
// pick.
|
// pick.
|
||||||
function mergeOriginList(base, ours, theirs) {
|
function mergeHostnameList(base, ours, theirs) {
|
||||||
return mergeListByIdentity(
|
return mergeListByIdentity(
|
||||||
base,
|
base,
|
||||||
ours,
|
ours,
|
||||||
theirs,
|
theirs,
|
||||||
(origin) => origin,
|
(hostname) => hostname,
|
||||||
(b, o, t) => t,
|
(b, o, t) => t,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSiteMap(base, ours, theirs) {
|
function mergeSiteMap(base, ours, theirs) {
|
||||||
return mergeMapByKey(base, ours, theirs, mergeOriginList);
|
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||||
}
|
}
|
||||||
|
|
||||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||||
@@ -422,8 +422,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// address) apply independently instead of colliding as the same field.
|
// address) apply independently instead of colliding as the same field.
|
||||||
//
|
//
|
||||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||||
// by address key and then by origin within each address's list), for the
|
// by address key and then by hostname within each address's list), for the
|
||||||
// identical reason: the background appends a newly approved/denied origin
|
// identical reason: the background appends a newly approved/denied hostname
|
||||||
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
||||||
// filters one out in place, from a different page. A whole-field diff here
|
// filters one out in place, from a different page. A whole-field diff here
|
||||||
// doesn't just lose data, it is a security defect — a stale page's save can
|
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,11 +34,6 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The characters that paint nothing; normalizeSymbol below says which they
|
|
||||||
// are. The signature screen marks them in a personal message
|
|
||||||
// (src/popup/views/approval.js).
|
|
||||||
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|
||||||
|
|
||||||
// Fold a symbol onto what a user actually sees, and no further:
|
// Fold a symbol onto what a user actually sees, and no further:
|
||||||
//
|
//
|
||||||
// NFKC collapses compatibility variants that render as the ASCII
|
// NFKC collapses compatibility variants that render as the ASCII
|
||||||
@@ -87,7 +82,7 @@ const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|||||||
function normalizeSymbol(symbol) {
|
function normalizeSymbol(symbol) {
|
||||||
return String(symbol || "")
|
return String(symbol || "")
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
.replace(INVISIBLE_CHARACTERS, "")
|
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -109,6 +104,5 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
INVISIBLE_CHARACTERS,
|
|
||||||
isSpoofedSymbol,
|
isSpoofedSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-86
@@ -84,43 +84,22 @@ 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".
|
||||||
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
||||||
|
|
||||||
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
|
|
||||||
// decode() is not told the network, so it takes either.
|
|
||||||
const WETH_ADDRESSES = [
|
|
||||||
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
|
||||||
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
|
||||||
];
|
|
||||||
|
|
||||||
// `decimals` is null when nothing knows this token's scale. It is not
|
// `decimals` is null when nothing knows this token's scale. It is not
|
||||||
// defaulted to 18: the swap lines land on the same approval screen as the
|
// defaulted to 18: the swap lines land on the same approval screen as the
|
||||||
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
||||||
@@ -199,7 +178,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 +185,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],
|
||||||
@@ -220,6 +198,11 @@ function decodeV2SwapExactIn(input) {
|
|||||||
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
|
//
|
||||||
|
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
|
||||||
|
// its command name and no token or amount detail. Kept for the fix, which is
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/283.
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
function decodeV2SwapExactOut(input) {
|
function decodeV2SwapExactOut(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -464,7 +447,6 @@ function decode(data, toAddress, sources) {
|
|||||||
let outputToken = null;
|
let outputToken = null;
|
||||||
let minOutput = null;
|
let minOutput = null;
|
||||||
let hasUnwrapWeth = false;
|
let hasUnwrapWeth = false;
|
||||||
let hasV2ExactOut = false;
|
|
||||||
const commandNames = [];
|
const commandNames = [];
|
||||||
|
|
||||||
// THE INVARIANT: an amount and the token it is counted in always come
|
// THE INVARIANT: an amount and the token it is counted in always come
|
||||||
@@ -517,13 +499,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) {
|
||||||
@@ -545,16 +521,6 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x09) {
|
|
||||||
// Buys exactly amountOut and spends at most amountInMax.
|
|
||||||
hasV2ExactOut = true;
|
|
||||||
const s = decodeV2SwapExactOut(inputs[i]);
|
|
||||||
if (s) {
|
|
||||||
setInputOnce(s.tokenIn, s.amountInMax);
|
|
||||||
setOutput(s.tokenOut, s.amountOut);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmdId === 0x0b) {
|
if (cmdId === 0x0b) {
|
||||||
const w = decodeWrapEth(inputs[i]);
|
const w = decodeWrapEth(inputs[i]);
|
||||||
if (w) {
|
if (w) {
|
||||||
@@ -593,17 +559,10 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
// Resolve token info. A null token on either side means the calldata
|
// Resolve token info. A null token on either side means the calldata
|
||||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
// named no currency for it; tokenInfo() refuses rather than calling it
|
||||||
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
|
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
||||||
// when the output side is WETH, or when no step set the output side.
|
// decode, and it is answered here rather than left to that rule.
|
||||||
// Any other output keeps its own token and figure: a swap that buys
|
|
||||||
// USDC and then unwraps the WETH it did not spend receives USDC.
|
|
||||||
const outputIsWeth =
|
|
||||||
present(outputToken) &&
|
|
||||||
WETH_ADDRESSES.includes(outputToken.toLowerCase());
|
|
||||||
const outputUnset = !present(outputToken) && !present(minOutput);
|
|
||||||
const inInfo = tokenInfo(inputToken, sources);
|
const inInfo = tokenInfo(inputToken, sources);
|
||||||
const outInfo =
|
const outInfo = hasUnwrapWeth
|
||||||
hasUnwrapWeth && (outputIsWeth || outputUnset)
|
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
: tokenInfo(outputToken, sources);
|
: tokenInfo(outputToken, sources);
|
||||||
|
|
||||||
@@ -644,33 +603,16 @@ 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) {
|
|
||||||
// A V2 exact-out swap spends at most this figure, whichever
|
|
||||||
// step set the line (its amountInMax, the WRAP_ETH of a swap
|
|
||||||
// paid in ETH, a permit), so it is said to be a maximum, in
|
|
||||||
// `raw` too: the wait, success and error screens show `raw` as
|
|
||||||
// the transaction's amount.
|
|
||||||
const most = amountText(inputAmount, inInfo);
|
|
||||||
amount = {
|
|
||||||
raw: "Up to " + most.raw,
|
|
||||||
display: "Up to " + most.display,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
amount = amountText(inputAmount, inInfo);
|
amount = amountText(inputAmount, inInfo);
|
||||||
}
|
}
|
||||||
@@ -724,15 +666,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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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(" ");
|
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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: < $0.01");
|
|
||||||
expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the balance warning on the address-removal confirmation", () => {
|
describe("the balance warning on the address-removal confirmation", () => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
// A nonce the page supplies is not used
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could
|
|
||||||
// replace one of the user's pending transactions (the same nonce at a higher
|
|
||||||
// fee) or leave the new one stuck behind a gap, so the transaction is always
|
|
||||||
// given the account's next nonce from the network.
|
|
||||||
//
|
|
||||||
// Driven through the preparation the background runs on a page's
|
|
||||||
// eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen,
|
|
||||||
// password and Confirm included, against a minimal DOM stub in the shape
|
|
||||||
// tests/approvalOrigin.test.js uses. The vault is mocked so that no password
|
|
||||||
// has to be hashed.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { Network, Transaction } = require("ethers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const { decryptWithPassword } = require("../src/shared/vault");
|
|
||||||
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
// A well-known test phrase, and its first address.
|
|
||||||
const PHRASE = "test test test test test test test test test test test junk";
|
|
||||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
// The account's next nonce, as the network reports it.
|
|
||||||
const NETWORK_NONCE = 7;
|
|
||||||
|
|
||||||
const network = {
|
|
||||||
getNetwork: async () => Network.from(1),
|
|
||||||
getTransactionCount: async () => NETWORK_NONCE,
|
|
||||||
estimateGas: async () => 21000n,
|
|
||||||
getFeeData: async () => ({
|
|
||||||
gasPrice: 2000000000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
listeners: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: (name, fn) => {
|
|
||||||
el.listeners[name] = el.listeners[name] || [];
|
|
||||||
el.listeners[name].push(fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(id) {
|
|
||||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the transaction approval screen the way the popup does: the background
|
|
||||||
// hands over the populated transaction and show() draws it. Returns every
|
|
||||||
// message the screen sends to the background. The background's answer to the
|
|
||||||
// signed transaction does not matter here; a retryable refusal keeps the
|
|
||||||
// screen where it is.
|
|
||||||
async function openTxApproval(approvedTx) {
|
|
||||||
const sent = [];
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
sent.push(msg);
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
|
|
||||||
return reply({ error: "Not sent.", retryable: true });
|
|
||||||
}
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "hd",
|
|
||||||
name: "Wallet 1",
|
|
||||||
xpub: "xpub-wallet-1",
|
|
||||||
encryptedSecret: "encrypted-secret-1",
|
|
||||||
nextIndex: 1,
|
|
||||||
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
return sent;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => {
|
|
||||||
// What the background does with the page's request before it opens the
|
|
||||||
// approval window.
|
|
||||||
const approvedTx = await prepareApprovalTx(network, FROM, {
|
|
||||||
from: FROM,
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0x0",
|
|
||||||
data: "0x",
|
|
||||||
nonce: "0x2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = await openTxApproval(approvedTx);
|
|
||||||
expect(node("approve-tx-nonce").textContent).toBe("7");
|
|
||||||
|
|
||||||
decryptWithPassword.mockResolvedValue(PHRASE);
|
|
||||||
node("approve-tx-password").value = "any password";
|
|
||||||
await click("btn-approve-tx");
|
|
||||||
|
|
||||||
const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
|
|
||||||
expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE);
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
// The connection, transaction and signature prompts name the site by its full
|
|
||||||
// origin, scheme and port included, not by its bare hostname
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
|
|
||||||
// or on another port, of a host the user trusts over https must not raise a
|
|
||||||
// prompt that reads as that trusted site.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/contractCreation.test.js uses.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
// The site asking, in cleartext and on a port, which is what the hostname
|
|
||||||
// alone, dapp.example, used to hide.
|
|
||||||
const ORIGIN = "http://dapp.example:8080";
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the prompt the background describes with `details`, the way the popup
|
|
||||||
// does: it asks for the approval and show() draws it.
|
|
||||||
async function openApproval(details) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
origin: ORIGIN,
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
...details,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [];
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the connection prompt shows the origin", async () => {
|
|
||||||
await openApproval({});
|
|
||||||
expect(node("approve-origin").textContent).toBe(ORIGIN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the transaction prompt shows the origin", async () => {
|
|
||||||
await openApproval({
|
|
||||||
type: "tx",
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0x0",
|
|
||||||
data: "0x",
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(node("approve-tx-origin").textContent).toBe(ORIGIN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the signature prompt shows the origin", async () => {
|
|
||||||
await openApproval({
|
|
||||||
type: "sign",
|
|
||||||
// "Hello" as the hex a dApp passes to personal_sign.
|
|
||||||
signParams: {
|
|
||||||
method: "personal_sign",
|
|
||||||
message: "0x48656c6c6f",
|
|
||||||
from: FROM,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
|
|
||||||
});
|
|
||||||
@@ -121,7 +121,7 @@ describe("prepareApprovalTx", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a gas limit and fee the request did fix, but not its nonce", async () => {
|
test("keeps a nonce, gas limit and fee the request did fix", async () => {
|
||||||
const approved = await prepareApprovalTx(
|
const approved = await prepareApprovalTx(
|
||||||
providerWith(),
|
providerWith(),
|
||||||
signer.address,
|
signer.address,
|
||||||
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
|
|||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
maxPriorityFeePerGas: "0x3b9aca00",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(approved.nonce).toBe("0x7");
|
expect(approved.nonce).toBe("0x2");
|
||||||
expect(approved.gasLimit).toBe("0x30d40");
|
expect(approved.gasLimit).toBe("0x30d40");
|
||||||
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const other = new Wallet(OTHER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const ORIGIN = "https://dapp.example";
|
const ORIGIN = "https://dapp.example";
|
||||||
|
const HOSTNAME = "dapp.example";
|
||||||
// A page the wallet has never been connected to, whose requests are refused.
|
// A page the wallet has never been connected to, whose requests are refused.
|
||||||
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
@@ -74,22 +75,6 @@ const NONCE = 7;
|
|||||||
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
||||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
||||||
|
|
||||||
// An EIP-712 document as a dApp passes it to eth_signTypedData_v4. The
|
|
||||||
// background only carries it to the approval screen, so a small one does.
|
|
||||||
const TYPED_DATA = JSON.stringify({
|
|
||||||
domain: { name: "AutistMask Test", version: "1", chainId: 1 },
|
|
||||||
primaryType: "Note",
|
|
||||||
types: {
|
|
||||||
EIP712Domain: [
|
|
||||||
{ name: "name", type: "string" },
|
|
||||||
{ name: "version", type: "string" },
|
|
||||||
{ name: "chainId", type: "uint256" },
|
|
||||||
],
|
|
||||||
Note: [{ name: "contents", type: "string" }],
|
|
||||||
},
|
|
||||||
message: { contents: "Hello AutistMask" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// The transaction the background populates and the approval screen displays.
|
// The transaction the background populates and the approval screen displays.
|
||||||
// The nonce is a parameter because the duplicate case turns on two artifacts
|
// The nonce is a parameter because the duplicate case turns on two artifacts
|
||||||
// differing in a field the dApp fixed nothing for.
|
// differing in a field the dApp fixed nothing for.
|
||||||
@@ -214,7 +199,7 @@ function loadBackground(options) {
|
|||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: "https://rpc.invalid",
|
rpcUrl: "https://rpc.invalid",
|
||||||
activeAddress: signer.address,
|
activeAddress: signer.address,
|
||||||
allowedSites: { [signer.address]: [ORIGIN] },
|
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +230,6 @@ function loadBackground(options) {
|
|||||||
// raised through action.openPopup() opens no window at all, so this is
|
// raised through action.openPopup() opens no window at all, so this is
|
||||||
// the only place its id appears.
|
// the only place its id appears.
|
||||||
const actionPopups = [];
|
const actionPopups = [];
|
||||||
const openPopup = jest.fn(() => Promise.resolve());
|
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
storage,
|
storage,
|
||||||
@@ -267,22 +251,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 });
|
||||||
@@ -313,7 +284,7 @@ function loadBackground(options) {
|
|||||||
// popup: no window is created, so windows.onRemoved can never
|
// popup: no window is created, so windows.onRemoved can never
|
||||||
// fire for it and the port disconnect is the only close signal
|
// fire for it and the port disconnect is the only close signal
|
||||||
// that exists.
|
// that exists.
|
||||||
...(opts.actionPopup ? { openPopup } : {}),
|
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,7 +331,7 @@ function loadBackground(options) {
|
|||||||
|
|
||||||
// The same for a message-signing approval, which pins the signing address
|
// The same for a message-signing approval, which pins the signing address
|
||||||
// at approval time in exactly the same way.
|
// at approval time in exactly the same way.
|
||||||
function requestSign(from, origin) {
|
function requestSign(from) {
|
||||||
let rpcResult = null;
|
let rpcResult = null;
|
||||||
messageListener(
|
messageListener(
|
||||||
{
|
{
|
||||||
@@ -368,7 +339,7 @@ function loadBackground(options) {
|
|||||||
method: "personal_sign",
|
method: "personal_sign",
|
||||||
params: [MESSAGE, from || signer.address],
|
params: [MESSAGE, from || signer.address],
|
||||||
},
|
},
|
||||||
{ origin: origin || ORIGIN },
|
{ origin: ORIGIN },
|
||||||
(r) => {
|
(r) => {
|
||||||
rpcResult = r;
|
rpcResult = r;
|
||||||
},
|
},
|
||||||
@@ -382,24 +353,6 @@ function loadBackground(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same through eth_signTypedData_v4, whose params name the address
|
|
||||||
// first and the typed data second.
|
|
||||||
function requestTypedData() {
|
|
||||||
let rpcResult = null;
|
|
||||||
messageListener(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_RPC",
|
|
||||||
method: "eth_signTypedData_v4",
|
|
||||||
params: [signer.address, TYPED_DATA],
|
|
||||||
},
|
|
||||||
{ origin: ORIGIN },
|
|
||||||
(r) => {
|
|
||||||
rpcResult = r;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return { result: () => rpcResult };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A dApp asking to connect. The origin defaults to one the persisted
|
// A dApp asking to connect. The origin defaults to one the persisted
|
||||||
// state has never allowed, so the request really does raise a prompt
|
// state has never allowed, so the request really does raise a prompt
|
||||||
// instead of being answered from allowedSites.
|
// instead of being answered from allowedSites.
|
||||||
@@ -474,15 +427,12 @@ function loadBackground(options) {
|
|||||||
send,
|
send,
|
||||||
requestTx,
|
requestTx,
|
||||||
requestSign,
|
requestSign,
|
||||||
requestTypedData,
|
|
||||||
requestSite,
|
requestSite,
|
||||||
connectApproval,
|
connectApproval,
|
||||||
closeWindow,
|
closeWindow,
|
||||||
broadcastTransaction,
|
broadcastTransaction,
|
||||||
created,
|
created,
|
||||||
removed,
|
removed,
|
||||||
actionPopups,
|
|
||||||
openPopup,
|
|
||||||
storage,
|
storage,
|
||||||
// The user switching account in the toolbar popup, as the background
|
// The user switching account in the toolbar popup, as the background
|
||||||
// sees it: the persisted active address changes underneath a pending
|
// sees it: the persisted active address changes underneath a pending
|
||||||
@@ -872,238 +822,6 @@ describe("one transaction approval at a time", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A page that asks again before the user has answered its last connection or
|
|
||||||
// signature request is refused, instead of opening one more window per call.
|
|
||||||
describe("one connection and one signature approval per site at a time", () => {
|
|
||||||
const PENDING_REFUSAL = {
|
|
||||||
error: {
|
|
||||||
code: -32002,
|
|
||||||
message: expect.stringMatching(/already waiting for your answer/),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test("a loop of eth_requestAccounts opens one approval and refuses the rest", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(requests[0].result()).toBeNull();
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the user has answered, the site may ask again.
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(requests[0].result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
const again = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(again.result()).toBeNull();
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a loop of personal_sign opens one approval and refuses the rest", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSign());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(requests[0].result()).toBeNull();
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a loop of eth_signTypedData_v4 opens one approval and refuses the rest", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestTypedData());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(requests[0].result()).toBeNull();
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pending personal_sign also refuses eth_signTypedData_v4", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
bg.requestSign();
|
|
||||||
const typed = bg.requestTypedData();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(typed.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("in the toolbar popup, a loop of eth_requestAccounts opens it once", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// A toolbar popup that closes before it connects tells the background
|
|
||||||
// nothing, so its prompt stays pending. Once the toolbar popup has been set
|
|
||||||
// to open something else, nothing shows that prompt, and the site asking
|
|
||||||
// again must show it again rather than be refused for good.
|
|
||||||
test("a toolbar prompt nothing shows any more is shown again when the site asks again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const first = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const id = first.id();
|
|
||||||
|
|
||||||
// Its popup closed without connecting. Another site's prompt takes the
|
|
||||||
// toolbar popup and is answered, which sets it back to the wallet.
|
|
||||||
const other = bg.requestSite(UNCONNECTED_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
const otherPort = bg.connectApproval(other.id());
|
|
||||||
otherPort.decide(false, false);
|
|
||||||
otherPort.disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(3);
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html?approval=" + id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The user answers it, and the first request gets that answer.
|
|
||||||
bg.connectApproval(id).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(first.result()).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user rejects a signature request from another site, which is
|
|
||||||
// connected already. Answering any approval sets the toolbar popup back to
|
|
||||||
// the wallet.
|
|
||||||
async function rejectSignatureFromAnotherSite(bg) {
|
|
||||||
const sign = bg.requestSign(undefined, ORIGIN);
|
|
||||||
await settle();
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
||||||
id: sign.id(),
|
|
||||||
approved: false,
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(sign.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a toolbar prompt is shown again after another site's signature request is answered", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const first = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const id = first.id();
|
|
||||||
|
|
||||||
// Its popup closed without connecting.
|
|
||||||
await rejectSignatureFromAnotherSite(bg);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(2);
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html?approval=" + id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a prompt in a window is not opened again when the site asks again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
// The browser will not open the toolbar popup, so the prompt goes to a
|
|
||||||
// window of its own.
|
|
||||||
bg.openPopup.mockImplementation(() =>
|
|
||||||
Promise.reject(new Error("no toolbar popup")),
|
|
||||||
);
|
|
||||||
bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
|
|
||||||
await rejectSignatureFromAnotherSite(bg);
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a prompt in a connected toolbar popup is not opened again when the site asks again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const first = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
// The popup is open and showing the prompt.
|
|
||||||
bg.connectApproval(first.id());
|
|
||||||
|
|
||||||
await rejectSignatureFromAnotherSite(bg);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("another site's connection request is not held up", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
bg.requestSite();
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
const other = bg.requestSite(UNCONNECTED_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(other.result()).toBeNull();
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("another site's signature request is not held up", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
// Connect a second site, so that it may ask for a signature at all.
|
|
||||||
const connecting = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const port = bg.connectApproval(connecting.id());
|
|
||||||
port.decide(true, false);
|
|
||||||
port.disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(connecting.result()).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
bg.requestSign();
|
|
||||||
const repeat = bg.requestSign();
|
|
||||||
const other = bg.requestSign(signer.address, FRESH_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(other.result()).toBeNull();
|
|
||||||
expect(bg.created).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A nonce collision found before the transaction reaches the network is the
|
// A nonce collision found before the transaction reaches the network is the
|
||||||
// one send failure the wallet can speak about with certainty. The user is told
|
// one send failure the wallet can speak about with certainty. The user is told
|
||||||
// it did not go out and to send it again, rather than being warned it might
|
// it did not go out and to send it again, rather than being warned it might
|
||||||
@@ -2248,27 +1966,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.
|
||||||
@@ -2496,54 +2193,12 @@ describe("removing an address ends a site's connection to it", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A remembered permission belongs to the origin it was granted to, scheme and
|
|
||||||
// port included (https://git.eeqj.de/sneak/AutistMask/issues/402). The stored
|
|
||||||
// state allows ORIGIN, https://dapp.example. A cleartext page on the same host,
|
|
||||||
// which a network attacker can serve, and another port on it are other sites.
|
|
||||||
describe("a remembered permission is held by the full origin", () => {
|
|
||||||
for (const origin of ["http://dapp.example", "https://dapp.example:8443"]) {
|
|
||||||
test(`an https grant does not authorise ${origin}`, async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
expect(await siteAccounts(bg, ORIGIN)).toEqual({
|
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg, origin)).toEqual({ result: [] });
|
|
||||||
const send = bg.requestTx(TX_PARAMS, origin);
|
|
||||||
await settle();
|
|
||||||
expect(send.result()).toEqual({
|
|
||||||
error: { code: 4100, message: "Unauthorized" },
|
|
||||||
});
|
|
||||||
expect(send.id()).toBeNull();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Remember stores the origin, so the cleartext page on that host is asked again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const granted = bg.requestSite(FRESH_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
const grantedId = granted.id();
|
|
||||||
bg.connectApproval(grantedId).decide(true, true);
|
|
||||||
await settle();
|
|
||||||
expect(granted.result()).toEqual({ result: [signer.address] });
|
|
||||||
expect(
|
|
||||||
bg.storage.read("autistmask").allowedSites[signer.address],
|
|
||||||
).toEqual([ORIGIN, FRESH_ORIGIN]);
|
|
||||||
|
|
||||||
const cleartext = bg.requestSite("http://fresh.example");
|
|
||||||
await settle();
|
|
||||||
// Unanswered: it is waiting on a prompt of its own.
|
|
||||||
expect(cleartext.result()).toBeNull();
|
|
||||||
expect(cleartext.id()).not.toBe(grantedId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Settings lists the sites allowed without "Remember", which only the
|
// Settings lists the sites allowed without "Remember", which only the
|
||||||
// background holds, and removing a site there, from either list, disconnects
|
// background holds, and removing a site there, from either list, disconnects
|
||||||
// it. These drive the real Settings view against the real background and
|
// it. These drive the real Settings view against the real background and
|
||||||
// click the [x] the user clicks.
|
// click the [x] the user clicks.
|
||||||
describe("removing a site in Settings disconnects it", () => {
|
describe("removing a site in Settings disconnects it", () => {
|
||||||
// The host of FRESH_ORIGIN on another port, which makes it another site.
|
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
|
||||||
const FRESH_OTHER_PORT = "https://fresh.example:8443";
|
const FRESH_OTHER_PORT = "https://fresh.example:8443";
|
||||||
|
|
||||||
// A site list's container. Its [x] buttons, data attributes and all, are
|
// A site list's container. Its [x] buttons, data attributes and all, are
|
||||||
@@ -2571,9 +2226,9 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The origins a site list shows.
|
// The hostnames a site list shows.
|
||||||
function listed(list) {
|
function listed(list) {
|
||||||
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/g)].map(
|
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
|
||||||
(match) => match[1],
|
(match) => match[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2604,10 +2259,10 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
allowed: element("settings-allowed-sites"),
|
allowed: element("settings-allowed-sites"),
|
||||||
connected: element("settings-connected-sites"),
|
connected: element("settings-connected-sites"),
|
||||||
// Click the [x] beside a site, and let what it sends run.
|
// Click the [x] beside a site, and let what it sends run.
|
||||||
remove: async (list, origin) => {
|
remove: async (list, hostname) => {
|
||||||
expect(listed(list)).toContain(origin);
|
expect(listed(list)).toContain(hostname);
|
||||||
const button = list.buttons.find(
|
const button = list.buttons.find(
|
||||||
(b) => b.dataset.origin === origin,
|
(b) => b.dataset.hostname === hostname,
|
||||||
);
|
);
|
||||||
await button.click();
|
await button.click();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -2619,15 +2274,14 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
delete global.document;
|
delete global.document;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Settings lists each site by its origin", async () => {
|
test("Settings lists a site connected without Remember", async () => {
|
||||||
const bg = loadBackground({ actionPopup: true });
|
const bg = loadBackground({ actionPopup: true });
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
await connect(bg, FRESH_ORIGIN, false);
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
|
||||||
|
|
||||||
const settings = await openSettings(bg);
|
const settings = await openSettings(bg);
|
||||||
|
|
||||||
expect(listed(settings.connected)).toEqual([FRESH_ORIGIN]);
|
expect(listed(settings.connected)).toEqual(["fresh.example"]);
|
||||||
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
|
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
|
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
|
||||||
@@ -2639,7 +2293,6 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
cb([
|
cb([
|
||||||
{ id: 1, url: FRESH_ORIGIN + "/app" },
|
{ id: 1, url: FRESH_ORIGIN + "/app" },
|
||||||
{ id: 2, url: ORIGIN + "/app" },
|
{ id: 2, url: ORIGIN + "/app" },
|
||||||
{ id: 3, url: FRESH_OTHER_PORT + "/app" },
|
|
||||||
]),
|
]),
|
||||||
sendMessage: (tabId, msg, cb) => {
|
sendMessage: (tabId, msg, cb) => {
|
||||||
sentToTabs.push({ tabId, msg });
|
sentToTabs.push({ tabId, msg });
|
||||||
@@ -2648,7 +2301,7 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
};
|
};
|
||||||
const settings = await openSettings(bg);
|
const settings = await openSettings(bg);
|
||||||
|
|
||||||
await settings.remove(settings.connected, FRESH_ORIGIN);
|
await settings.remove(settings.connected, "fresh.example");
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
||||||
expect(sentToTabs).toEqual([
|
expect(sentToTabs).toEqual([
|
||||||
@@ -2668,45 +2321,20 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// One origin can hold both kinds of connection under two addresses:
|
// The same hostname can hold both kinds of connection: one origin allowed
|
||||||
// remembered for one, allowed without Remember for the other.
|
// without Remember, then another, on a different port, allowed with it.
|
||||||
test("removing a remembered site also ends its connection made without Remember", async () => {
|
test("removing a remembered site also ends its connection made without Remember", async () => {
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const stored = bg.storage.read("autistmask");
|
|
||||||
stored.wallets[0].addresses.push({
|
|
||||||
address: other.address,
|
|
||||||
balance: "0",
|
|
||||||
tokenBalances: [],
|
|
||||||
});
|
|
||||||
bg.storage.write("autistmask", stored);
|
|
||||||
await connect(bg, FRESH_ORIGIN, true);
|
|
||||||
bg.setActiveAddress(other.address);
|
|
||||||
const pending = bg.requestSite(FRESH_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
bg.connectApproval(pending.id()).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [other.address] });
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
await settings.remove(settings.allowed, FRESH_ORIGIN);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a remembered site leaves the same host on another port connected", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
const bg = loadBackground({ actionPopup: true });
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
await connect(bg, FRESH_ORIGIN, false);
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
await connect(bg, FRESH_OTHER_PORT, true);
|
||||||
const settings = await openSettings(bg);
|
const settings = await openSettings(bg);
|
||||||
|
|
||||||
await settings.remove(settings.allowed, FRESH_OTHER_PORT);
|
await settings.remove(settings.allowed, "fresh.example");
|
||||||
|
|
||||||
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
|
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
|
||||||
result: [],
|
result: [],
|
||||||
});
|
});
|
||||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
|
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a page can neither remove a site nor list the connected ones", async () => {
|
test("a page can neither remove a site nor list the connected ones", async () => {
|
||||||
@@ -2715,7 +2343,7 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
const page = { url: FRESH_ORIGIN + "/index.html" };
|
const page = { url: FRESH_ORIGIN + "/index.html" };
|
||||||
|
|
||||||
const remove = bg.send(
|
const remove = bg.send(
|
||||||
{ type: "AUTISTMASK_REMOVE_SITE", origin: FRESH_ORIGIN },
|
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
|
||||||
page,
|
page,
|
||||||
);
|
);
|
||||||
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
|
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
|
||||||
@@ -2729,77 +2357,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." },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const signer = new Wallet(SIGNER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -80,7 +81,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: net.defaultRpcUrl,
|
rpcUrl: net.defaultRpcUrl,
|
||||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
lastBalanceRefresh: 0,
|
lastBalanceRefresh: 0,
|
||||||
|
|||||||
@@ -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("< $0.01");
|
|
||||||
expect(html).not.toContain("< $0.01");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|||||||
|
|
||||||
// The site the persisted state has connected, and one it has never heard of.
|
// The site the persisted state has connected, and one it has never heard of.
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const STRANGER_ORIGIN = "https://stranger.example";
|
const STRANGER_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -85,7 +86,7 @@ function loadBackground() {
|
|||||||
tokenHolderCache: {},
|
tokenHolderCache: {},
|
||||||
fraudContracts: [],
|
fraudContracts: [],
|
||||||
activeAddress: ADDRESS,
|
activeAddress: ADDRESS,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
};
|
};
|
||||||
const storage = makeStorageStub({ autistmask: persisted });
|
const storage = makeStorageStub({ autistmask: persisted });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { networkById } = require("../src/shared/networks");
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const UNKNOWN_ORIGIN = "https://stranger.example";
|
const UNKNOWN_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -40,7 +41,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: networkById(networkId).defaultRpcUrl,
|
rpcUrl: networkById(networkId).defaultRpcUrl,
|
||||||
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { makeStorageStub } = require("./support/storageStub");
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
@@ -49,7 +50,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: CUSTOM_RPC,
|
rpcUrl: CUSTOM_RPC,
|
||||||
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
@@ -167,7 +168,7 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(after.wallets).toEqual(walletFixture());
|
expect(after.wallets).toEqual(walletFixture());
|
||||||
expect(after.hasWallet).toBe(true);
|
expect(after.hasWallet).toBe(true);
|
||||||
expect(after.activeAddress).toBe(ADDRESS);
|
expect(after.activeAddress).toBe(ADDRESS);
|
||||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
|
||||||
expect(after.trackedTokens).toEqual([
|
expect(after.trackedTokens).toEqual([
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const signer = new Wallet(SIGNER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
@@ -66,7 +67,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: net.defaultRpcUrl,
|
rpcUrl: net.defaultRpcUrl,
|
||||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
// The recipient line of a contract creation
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
|
|
||||||
//
|
|
||||||
// A transaction with no `to` creates a contract. The approval screen, the
|
|
||||||
// wait, success and error screens, the transaction detail view and the
|
|
||||||
// transaction history rows each say so in a sentence, where they used to show
|
|
||||||
// a blank line (an empty address, with a colour dot whose colour was
|
|
||||||
// `undefined`) or, on the approval screen, "(contract creation)". A
|
|
||||||
// transaction with a real `to` still shows that address.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/typedDataPermit.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 wait screen polls for a receipt; this one never arrives.
|
|
||||||
jest.mock("../src/shared/balances", () => ({
|
|
||||||
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
|
|
||||||
refreshBalances: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 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 { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
|
||||||
const home = require("../src/popup/views/home");
|
|
||||||
const addressDetail = require("../src/popup/views/addressDetail");
|
|
||||||
const addressToken = require("../src/popup/views/addressToken");
|
|
||||||
|
|
||||||
const SENTENCE =
|
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
|
||||||
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const TX_HASH =
|
|
||||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
|
||||||
// Init code for a contract creation's data.
|
|
||||||
const INIT_CODE = "0x600160005500";
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The line a transaction with a real `to` shows: that address, and nothing
|
|
||||||
// left over from an empty one.
|
|
||||||
function expectAddressLine(html) {
|
|
||||||
expect(html).toContain(RECIPIENT);
|
|
||||||
expect(html).not.toContain(SENTENCE);
|
|
||||||
expect(html).not.toContain("undefined");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
state.wallets = [];
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
txStatus.init({ doRefreshAndRender: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
txStatus.endWait();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open the transaction approval screen the way the popup does: the background
|
|
||||||
// hands over the populated transaction and show() draws it.
|
|
||||||
async function openTxApproval(to, data) {
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to,
|
|
||||||
value: "0x0",
|
|
||||||
data,
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the transaction approval screen", () => {
|
|
||||||
test("a contract creation says so instead of naming a contract", async () => {
|
|
||||||
await openTxApproval(null, INIT_CODE);
|
|
||||||
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address", async () => {
|
|
||||||
await openTxApproval(RECIPIENT, "0x");
|
|
||||||
expectAddressLine(node("approve-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// approval.js carries a contract creation to these screens with `to` as "".
|
|
||||||
describe("the wait, success and error screens", () => {
|
|
||||||
const creation = {
|
|
||||||
to: "",
|
|
||||||
amount: "0.0000",
|
|
||||||
token: "ETH",
|
|
||||||
tokenSymbol: null,
|
|
||||||
};
|
|
||||||
const transfer = { ...creation, to: RECIPIENT };
|
|
||||||
|
|
||||||
test("a contract creation says so on the wait screen", () => {
|
|
||||||
txStatus.showWait(creation, TX_HASH);
|
|
||||||
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address on the wait screen", () => {
|
|
||||||
txStatus.showWait(transfer, TX_HASH);
|
|
||||||
expectAddressLine(node("wait-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract creation says so on the success and error screens", () => {
|
|
||||||
state.viewData = {
|
|
||||||
amount: "0.0000",
|
|
||||||
symbol: "ETH",
|
|
||||||
to: "",
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 1,
|
|
||||||
};
|
|
||||||
txStatus.renderSuccess();
|
|
||||||
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
|
|
||||||
txStatus.showError(creation, TX_HASH, "The transaction failed.");
|
|
||||||
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address on the success and error screens", () => {
|
|
||||||
state.viewData = {
|
|
||||||
amount: "0.0050",
|
|
||||||
symbol: "ETH",
|
|
||||||
to: RECIPIENT,
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 1,
|
|
||||||
};
|
|
||||||
txStatus.renderSuccess();
|
|
||||||
expectAddressLine(node("success-tx-to").innerHTML);
|
|
||||||
|
|
||||||
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
|
|
||||||
expectAddressLine(node("error-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A transaction FROM sent, as the history lists hold it. The explorer reports a
|
|
||||||
// contract creation with no `to`, which src/shared/transactions.js turns into
|
|
||||||
// `to: ""`.
|
|
||||||
function historyTx(to) {
|
|
||||||
return {
|
|
||||||
hash: TX_HASH,
|
|
||||||
from: FROM,
|
|
||||||
to,
|
|
||||||
value: "0.0000",
|
|
||||||
exactValue: "0.0",
|
|
||||||
rawAmount: "0",
|
|
||||||
rawUnit: "wei",
|
|
||||||
symbol: "ETH",
|
|
||||||
timestamp: 1790000000,
|
|
||||||
isError: false,
|
|
||||||
directionLabel: "Sent",
|
|
||||||
direction: "sent",
|
|
||||||
contractAddress: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The detail view is opened with the transaction a history row holds.
|
|
||||||
describe("the transaction detail view", () => {
|
|
||||||
test("a contract creation says so", () => {
|
|
||||||
transactionDetail.show(historyTx(""));
|
|
||||||
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
|
|
||||||
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address", () => {
|
|
||||||
transactionDetail.show(historyTx(RECIPIENT));
|
|
||||||
expectAddressLine(node("tx-detail-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH).
|
|
||||||
describe.each([
|
|
||||||
["Home", "home-tx-list", () => home.render({})],
|
|
||||||
["AddressDetail", "tx-list", () => addressDetail.show()],
|
|
||||||
["AddressToken", "address-token-tx-list", () => addressToken.show()],
|
|
||||||
])("the transaction history on %s", (_name, listId, open) => {
|
|
||||||
async function rowsFor(tx) {
|
|
||||||
mockHistory = [tx];
|
|
||||||
open();
|
|
||||||
// The list is drawn once the history has been fetched.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
return node(listId).innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
name: "Main",
|
|
||||||
type: "key",
|
|
||||||
addresses: [{ address: FROM, balance: "0.0000" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
state.selectedToken = "ETH";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
|
||||||
const html = await rowsFor(historyTx(""));
|
|
||||||
expect(html).toContain(SENTENCE);
|
|
||||||
expect(html).not.toContain("bg-[#");
|
|
||||||
expect(html).not.toContain("am-address");
|
|
||||||
expect(html).not.toContain("undefined");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its colour dot and address", async () => {
|
|
||||||
const html = await rowsFor(historyTx(RECIPIENT));
|
|
||||||
expectAddressLine(html);
|
|
||||||
expect(html).toContain("bg-[#");
|
|
||||||
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// What debugFetch writes to the console in debug mode.
|
|
||||||
//
|
|
||||||
// RPC providers put the API key in the URL's path or query string, and the
|
|
||||||
// debug log used to print the whole URL and request body, so turning debug
|
|
||||||
// mode on wrote the key to the console
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
|
|
||||||
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
|
|
||||||
// the request.
|
|
||||||
|
|
||||||
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
setRuntimeDebug(false);
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
|
|
||||||
setRuntimeDebug(true);
|
|
||||||
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
|
|
||||||
|
|
||||||
await debugFetch(
|
|
||||||
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "eth_chainId",
|
|
||||||
params: [],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const logged = consoleLog.mock.calls.flat().join(" ");
|
|
||||||
expect(logged).not.toContain("PATHKEY123");
|
|
||||||
expect(logged).not.toContain("QUERYTOKEN456");
|
|
||||||
expect(logged).toContain("https://rpc.example.invalid");
|
|
||||||
expect(logged).toContain("eth_chainId");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the origin leaves out a user name and password in the URL", () => {
|
|
||||||
expect(
|
|
||||||
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
|
|
||||||
).toBe("https://rpc.example.invalid");
|
|
||||||
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
|
|
||||||
"wss://rpc.example.invalid:8546",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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) {
|
||||||
@@ -143,14 +140,8 @@ function load() {
|
|||||||
state.selectedWallet = 0;
|
state.selectedWallet = 0;
|
||||||
state.selectedAddress = 0;
|
state.selectedAddress = 0;
|
||||||
state.activeAddress = A0;
|
state.activeAddress = A0;
|
||||||
state.allowedSites = {
|
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
|
||||||
[A0]: ["https://a.example"],
|
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
|
||||||
[B0]: ["https://b.example"],
|
|
||||||
};
|
|
||||||
state.deniedSites = {
|
|
||||||
[B0]: ["https://c.example"],
|
|
||||||
[C0]: ["https://d.example"],
|
|
||||||
};
|
|
||||||
state.viewStack = ["main", "settings"];
|
state.viewStack = ["main", "settings"];
|
||||||
state.currentView = "settings";
|
state.currentView = "settings";
|
||||||
|
|
||||||
@@ -345,72 +336,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", () => {
|
||||||
@@ -463,8 +388,8 @@ describe("deleting without the password", () => {
|
|||||||
await click("btn-delete-wallet-lost-confirm");
|
await click("btn-delete-wallet-lost-confirm");
|
||||||
|
|
||||||
const saved = (await storage.get("autistmask")).autistmask;
|
const saved = (await storage.get("autistmask")).autistmask;
|
||||||
expect(saved.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||||
expect(saved.deniedSites).toEqual({ [C0]: ["https://d.example"] });
|
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The route shares finishDelete() with the password route, so the
|
// The route shares finishDelete() with the password route, so the
|
||||||
@@ -512,7 +437,7 @@ describe("deleting without the password", () => {
|
|||||||
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
||||||
const { deleteWallet, state, storage } = load();
|
const { deleteWallet, state, storage } = load();
|
||||||
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
||||||
state.allowedSites = { [A0]: ["https://a.example"] };
|
state.allowedSites = { [A0]: ["a.example"] };
|
||||||
state.deniedSites = {};
|
state.deniedSites = {};
|
||||||
|
|
||||||
await openLostPassword(deleteWallet, 0);
|
await openLostPassword(deleteWallet, 0);
|
||||||
|
|||||||
+10
-145
@@ -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");
|
||||||
@@ -574,10 +438,11 @@ step(
|
|||||||
await d.switchToWindow(popup);
|
await d.switchToWindow(popup);
|
||||||
await d.waitVisible("#view-approve-site");
|
await d.waitVisible("#view-approve-site");
|
||||||
|
|
||||||
const origin = await d.text("#approve-origin");
|
const hostname = await d.text("#approve-hostname");
|
||||||
assert(
|
assert(
|
||||||
origin === env.server.origin,
|
hostname === "127.0.0.1",
|
||||||
"the site prompt names the wrong origin: " + JSON.stringify(origin),
|
"the site prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(hostname),
|
||||||
);
|
);
|
||||||
const shown = await d.text("#approve-address");
|
const shown = await d.text("#approve-address");
|
||||||
assert(
|
assert(
|
||||||
@@ -629,16 +494,16 @@ step(
|
|||||||
|
|
||||||
const screen = await d.execute(
|
const screen = await d.execute(
|
||||||
`return {
|
`return {
|
||||||
origin: document.getElementById("approve-sign-origin").textContent,
|
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||||
type: document.getElementById("approve-sign-type").textContent,
|
type: document.getElementById("approve-sign-type").textContent,
|
||||||
message: document.getElementById("approve-sign-message").textContent,
|
message: document.getElementById("approve-sign-message").textContent,
|
||||||
from: document.getElementById("approve-sign-from").textContent,
|
from: document.getElementById("approve-sign-from").textContent,
|
||||||
};`,
|
};`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.origin === env.server.origin,
|
screen.hostname === "127.0.0.1",
|
||||||
"the sign prompt names the wrong origin: " +
|
"the sign prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.type === "Personal message",
|
screen.type === "Personal message",
|
||||||
@@ -706,7 +571,7 @@ step(
|
|||||||
|
|
||||||
const screen = await d.execute(
|
const screen = await d.execute(
|
||||||
`return {
|
`return {
|
||||||
origin: document.getElementById("approve-tx-origin").textContent,
|
hostname: document.getElementById("approve-tx-hostname").textContent,
|
||||||
from: document.getElementById("approve-tx-from").textContent,
|
from: document.getElementById("approve-tx-from").textContent,
|
||||||
to: document.getElementById("approve-tx-to").textContent,
|
to: document.getElementById("approve-tx-to").textContent,
|
||||||
value: document.getElementById("approve-tx-value").textContent,
|
value: document.getElementById("approve-tx-value").textContent,
|
||||||
@@ -717,9 +582,9 @@ step(
|
|||||||
};`,
|
};`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.origin === env.server.origin,
|
screen.hostname === "127.0.0.1",
|
||||||
"the transaction prompt names the wrong origin: " +
|
"the transaction prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
||||||
|
|||||||
+11
-37
@@ -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>}>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
+113
-918
File diff suppressed because it is too large
Load Diff
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'"],
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -59,32 +59,24 @@ describe("the floor under allowedSites and deniedSites", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`an ${field} entry whose value is not an origin list is dropped`, () => {
|
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
|
||||||
for (const bad of [
|
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
|
||||||
"https://dapp.example",
|
|
||||||
42,
|
|
||||||
null,
|
|
||||||
{ a: 1 },
|
|
||||||
true,
|
|
||||||
]) {
|
|
||||||
expect(
|
expect(
|
||||||
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
||||||
).toEqual({});
|
).toEqual({});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`an origin that is not text is dropped from an ${field} entry`, () => {
|
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
|
||||||
expect(
|
expect(
|
||||||
normalizePersisted({
|
normalizePersisted({
|
||||||
[field]: {
|
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
|
||||||
[ADDRESS]: [42, null, "https://dapp.example", {}],
|
|
||||||
},
|
|
||||||
})[field],
|
})[field],
|
||||||
).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`a real ${field} map survives, copied not shared`, () => {
|
test(`a real ${field} map survives, copied not shared`, () => {
|
||||||
const saved = { [field]: { [ADDRESS]: ["https://dapp.example"] } };
|
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
|
||||||
|
|
||||||
const out = normalizePersisted(saved);
|
const out = normalizePersisted(saved);
|
||||||
|
|
||||||
@@ -95,13 +87,10 @@ describe("the floor under allowedSites and deniedSites", () => {
|
|||||||
|
|
||||||
test(`a good ${field} entry beside a malformed one survives`, () => {
|
test(`a good ${field} entry beside a malformed one survives`, () => {
|
||||||
const out = normalizePersisted({
|
const out = normalizePersisted({
|
||||||
[field]: {
|
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
|
||||||
[TOKEN_ADDRESS]: 42,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(out[field]).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
||||||
@@ -111,7 +100,7 @@ describe("the floor under allowedSites and deniedSites", () => {
|
|||||||
// saveState()'s merge hands to the prototype setter on the next
|
// saveState()'s merge hands to the prototype setter on the next
|
||||||
// write.
|
// write.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
'{"' + field + '":{"__proto__":["https://evil.invalid"]}}',
|
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
||||||
);
|
);
|
||||||
|
|
||||||
const out = normalizePersisted(saved);
|
const out = normalizePersisted(saved);
|
||||||
@@ -208,7 +197,7 @@ describe("a malformed allowedSites entry", () => {
|
|||||||
const MALFORMED = [
|
const MALFORMED = [
|
||||||
{ name: "a string", value: "notalist" },
|
{ name: "a string", value: "notalist" },
|
||||||
{ name: "a number", value: 42 },
|
{ name: "a number", value: 42 },
|
||||||
{ name: "a record", value: { origins: ["https://dapp.example"] } },
|
{ name: "a record", value: { hostnames: ["dapp.example"] } },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { name, value } of MALFORMED) {
|
for (const { name, value } of MALFORMED) {
|
||||||
@@ -242,7 +231,7 @@ describe("a malformed allowedSites entry", () => {
|
|||||||
const env = await bootPopup(
|
const env = await bootPopup(
|
||||||
unversionedValidProfile({
|
unversionedValidProfile({
|
||||||
allowedSites: {
|
allowedSites: {
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
[ADDRESS]: ["dapp.example"],
|
||||||
[TOKEN_ADDRESS]: "notalist",
|
[TOKEN_ADDRESS]: "notalist",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -250,7 +239,7 @@ describe("a malformed allowedSites entry", () => {
|
|||||||
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
expect(env.pageErrors).toEqual([]);
|
||||||
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
[ADDRESS]: ["dapp.example"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ const CONTRACT = [
|
|||||||
[ADDRESS],
|
[ADDRESS],
|
||||||
{ [ADDRESS]: 42 },
|
{ [ADDRESS]: 42 },
|
||||||
{ [ADDRESS]: [42, null, {}] },
|
{ [ADDRESS]: [42, null, {}] },
|
||||||
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
|
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||||
],
|
],
|
||||||
holds: siteMapHolds,
|
holds: siteMapHolds,
|
||||||
},
|
},
|
||||||
@@ -177,7 +177,7 @@ const CONTRACT = [
|
|||||||
[ADDRESS],
|
[ADDRESS],
|
||||||
{ [ADDRESS]: 42 },
|
{ [ADDRESS]: 42 },
|
||||||
{ [ADDRESS]: [42, null, {}] },
|
{ [ADDRESS]: [42, null, {}] },
|
||||||
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
|
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||||
],
|
],
|
||||||
holds: siteMapHolds,
|
holds: siteMapHolds,
|
||||||
},
|
},
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
// The signature prompt shows a personal message as the bytes that are signed
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
|
|
||||||
// text it decodes to with control characters, line and paragraph separators
|
|
||||||
// and characters that paint nothing marked rather than obeyed, markup shown as
|
|
||||||
// text, laid out in byte order, and a message that is not hex as plain text
|
|
||||||
// that cannot be signed.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
|
|
||||||
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { hexlify, toUtf8Bytes } = require("ethers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
|
|
||||||
// Built from their code points so that this file holds none of them.
|
|
||||||
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
|
|
||||||
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
|
|
||||||
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
|
||||||
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
|
|
||||||
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
|
|
||||||
const HANGUL_FILLER = String.fromCodePoint(0x3164);
|
|
||||||
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
|
|
||||||
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the signature prompt for a personal_sign of `message`, the way the
|
|
||||||
// popup does: it asks the background for the approval and show() draws it.
|
|
||||||
async function openPersonalSign(message) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
type: "sign",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
signParams: { method: "personal_sign", message, from: FROM },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The message box's markup as the text a reader sees: tags dropped.
|
|
||||||
function shownMessage() {
|
|
||||||
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [];
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a right-to-left override is marked, so the text reads in byte order", async () => {
|
|
||||||
// Obeyed, the override shows "0001" as "1000".
|
|
||||||
const text =
|
|
||||||
"Pay " +
|
|
||||||
RIGHT_TO_LEFT_OVERRIDE +
|
|
||||||
"0001" +
|
|
||||||
POP_DIRECTIONAL_FORMATTING +
|
|
||||||
" ETH";
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes(text)));
|
|
||||||
const html = node("approve-sign-message").innerHTML;
|
|
||||||
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
|
|
||||||
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
|
|
||||||
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a zero-width character is marked", async () => {
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
|
|
||||||
);
|
|
||||||
expect(node("approve-sign-message").innerHTML).not.toContain(
|
|
||||||
ZERO_WIDTH_SPACE,
|
|
||||||
);
|
|
||||||
expect(shownMessage()).toBe("payU+200Bpal.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("variation selectors and a Hangul filler are marked", async () => {
|
|
||||||
// Each paints nothing, so a page could hide bytes after "Sign in".
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(
|
|
||||||
toUtf8Bytes(
|
|
||||||
"Sign in" +
|
|
||||||
VARIATION_SELECTOR_1 +
|
|
||||||
VARIATION_SELECTOR_17 +
|
|
||||||
HANGUL_FILLER,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the message is laid out in byte order", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
|
|
||||||
expect(
|
|
||||||
node("approve-sign-message").classList.contains("am-byte-order"),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a control character other than a line feed is marked", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
|
|
||||||
expect(shownMessage()).toBe("aU+0000bU+0009c");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("line and paragraph separators are marked", async () => {
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order
|
|
||||||
// layout for everything after it.
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
|
|
||||||
);
|
|
||||||
const html = node("approve-sign-message").innerHTML;
|
|
||||||
expect(html).not.toContain(LINE_SEPARATOR);
|
|
||||||
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
|
|
||||||
expect(shownMessage()).toBe("aU+2028bU+2029");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a line feed is shown as a line break", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
|
|
||||||
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The message box is written as HTML, so a site's markup has to arrive there
|
|
||||||
// escaped, as the text it is.
|
|
||||||
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
|
|
||||||
["a message that is not hex", MARKUP],
|
|
||||||
])("markup in %s is shown as text, not as markup", async (_, message) => {
|
|
||||||
await openPersonalSign(message);
|
|
||||||
expect(node("approve-sign-message").innerHTML).toBe(
|
|
||||||
"<b>x</b><img src=x onerror=alert(1)>",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the raw hex is shown alongside the text", async () => {
|
|
||||||
await openPersonalSign("0x48656c6c6f");
|
|
||||||
expect(shownMessage()).toBe("Hello");
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
|
|
||||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
|
|
||||||
await openPersonalSign("0X48656C6C6F");
|
|
||||||
expect(shownMessage()).toBe("Hello");
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
|
|
||||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bytes that are not text are shown only as hex", async () => {
|
|
||||||
await openPersonalSign("0xff00");
|
|
||||||
expect(node("approve-sign-message").textContent).toBe(
|
|
||||||
"This message is not text.",
|
|
||||||
);
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0xff00");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a message that is not hex is shown as text and cannot be signed", async () => {
|
|
||||||
await openPersonalSign("Hello world");
|
|
||||||
expect(shownMessage()).toBe("Hello world");
|
|
||||||
expect(node("approve-sign-error").textContent).toBe(
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.",
|
|
||||||
);
|
|
||||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
|
||||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
// What reaches the console when the RPC endpoint answers with an HTTP error.
|
|
||||||
//
|
|
||||||
// RPC providers put the API key in the endpoint URL's path or query string.
|
|
||||||
// When the endpoint answers with an HTTP error (a wrong or expired key, a rate
|
|
||||||
// limit, a server error), the error ethers throws carries the full request URL
|
|
||||||
// in its message, so a line logging that message printed the key
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). Those lines log the
|
|
||||||
// error's short message, which names the HTTP status and not the URL.
|
|
||||||
//
|
|
||||||
// The real ethers provider runs; only its HTTP transport is replaced, by one
|
|
||||||
// that answers every request with 401 Unauthorized. Debug mode is on, so
|
|
||||||
// every log level is printed.
|
|
||||||
|
|
||||||
const { FetchRequest } = require("ethers");
|
|
||||||
const {
|
|
||||||
getProvider,
|
|
||||||
lookupTokenInfo,
|
|
||||||
refreshBalances,
|
|
||||||
} = require("../src/shared/balances");
|
|
||||||
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
|
||||||
const { resolveEnsName } = require("../src/shared/ens");
|
|
||||||
const { setRuntimeDebug } = require("../src/shared/log");
|
|
||||||
|
|
||||||
const RPC_URL = "https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
|
|
||||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
let printed;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setRuntimeDebug(true);
|
|
||||||
printed = [];
|
|
||||||
for (const method of ["log", "warn", "error"]) {
|
|
||||||
jest.spyOn(console, method).mockImplementation((...args) => {
|
|
||||||
printed.push(args.map(String).join(" "));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
FetchRequest.registerGetUrl(async () => ({
|
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: "Unauthorized",
|
|
||||||
headers: {},
|
|
||||||
body: new Uint8Array(),
|
|
||||||
}));
|
|
||||||
// The explorer requests the balance refresh makes go nowhere.
|
|
||||||
globalThis.fetch = jest.fn(async () => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
setRuntimeDebug(false);
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The line carrying `label` was printed and names the HTTP status, and nothing
|
|
||||||
// printed carries the key.
|
|
||||||
function expectFailureLoggedWithoutKey(label) {
|
|
||||||
const line = printed.find((text) => text.includes(label));
|
|
||||||
expect(line).toContain("401");
|
|
||||||
const all = printed.join("\n");
|
|
||||||
expect(all).not.toContain("PATHKEY123");
|
|
||||||
expect(all).not.toContain("QUERYTOKEN456");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ethers puts the URL in the error message, not in the short message", async () => {
|
|
||||||
const provider = getProvider(RPC_URL, "mainnet");
|
|
||||||
const error = await provider.getCode(ADDRESS).catch((e) => e);
|
|
||||||
expect(error.message).toContain("PATHKEY123");
|
|
||||||
expect(error.shortMessage).not.toContain("PATHKEY123");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the recipient checks before a send", async () => {
|
|
||||||
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
|
|
||||||
expectFailureLoggedWithoutKey("contract check failed");
|
|
||||||
expectFailureLoggedWithoutKey("tx count check failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the ENS reverse lookup", async () => {
|
|
||||||
expect(await resolveEnsName(ADDRESS, RPC_URL, "mainnet")).toBeNull();
|
|
||||||
expectFailureLoggedWithoutKey("ENS reverse lookup failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the balance refresh", async () => {
|
|
||||||
const wallets = [{ addresses: [{ address: ADDRESS }] }];
|
|
||||||
await refreshBalances(
|
|
||||||
wallets,
|
|
||||||
RPC_URL,
|
|
||||||
"https://explorer.example.invalid/api/v2",
|
|
||||||
[],
|
|
||||||
"mainnet",
|
|
||||||
);
|
|
||||||
expectFailureLoggedWithoutKey("ETH balance failed");
|
|
||||||
expectFailureLoggedWithoutKey("ENS reverse failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The lookup's first line, at debug level, names the RPC endpoint; the check
|
|
||||||
// of everything printed covers it too.
|
|
||||||
test("the token lookup", async () => {
|
|
||||||
await expect(lookupTokenInfo(ADDRESS, RPC_URL, "mainnet")).rejects.toThrow(
|
|
||||||
"Not a valid ERC-20 token",
|
|
||||||
);
|
|
||||||
expectFailureLoggedWithoutKey("symbol() failed:");
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
// Which site a page's request is attributed to.
|
|
||||||
//
|
|
||||||
// The background takes a request's origin from what the browser says sent the
|
|
||||||
// message: sender.origin, or on Firefox before 126, which has no
|
|
||||||
// sender.origin, the origin of sender.url — the frame that sent it. It used to
|
|
||||||
// fall back to the tab's page and then to an origin the message itself
|
|
||||||
// carried, so a request from a frame was credited to the site embedding it,
|
|
||||||
// and a request the browser said nothing about was credited to whatever the
|
|
||||||
// page wrote (https://git.eeqj.de/sneak/AutistMask/issues/407).
|
|
||||||
//
|
|
||||||
// Every sender here lacks sender.origin, as on old Firefox. The connection
|
|
||||||
// check on eth_accounts is what shows which site a request was credited to.
|
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
|
|
||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
// The site the persisted state has connected, and one it has never heard of.
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
|
||||||
const STRANGER_ORIGIN = "https://stranger.example";
|
|
||||||
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.chrome;
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadBackground() {
|
|
||||||
jest.resetModules();
|
|
||||||
|
|
||||||
jest.doMock("../src/shared/balances", () => ({
|
|
||||||
getProvider: () => ({}),
|
|
||||||
refreshBalances: jest.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
|
||||||
isPhishingDomain: () => false,
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/shared/alarms", () => ({
|
|
||||||
BALANCE_REFRESH_ALARM: "balance",
|
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
||||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
|
||||||
registerAlarmHandlers: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const storage = makeStorageStub({
|
|
||||||
autistmask: {
|
|
||||||
networkId: "mainnet",
|
|
||||||
wallets: [
|
|
||||||
{
|
|
||||||
name: "Wallet 1",
|
|
||||||
type: "hd",
|
|
||||||
addresses: [
|
|
||||||
{ address: ADDRESS, balance: "0", tokenBalances: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeAddress: ADDRESS,
|
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
|
||||||
deniedSites: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let messageListener = null;
|
|
||||||
global.chrome = {
|
|
||||||
storage,
|
|
||||||
runtime: {
|
|
||||||
getURL: (path) => "chrome-extension://autistmask/" + path,
|
|
||||||
onMessage: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
messageListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onConnect: { addListener: () => {} },
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
windows: { onRemoved: { addListener: () => {} } },
|
|
||||||
action: { setPopup: () => {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
require("../src/background/index");
|
|
||||||
|
|
||||||
// Ask for eth_accounts. `claimedOrigin` is an origin written into the
|
|
||||||
// message, as the content script used to send.
|
|
||||||
return async function accounts(sender, claimedOrigin) {
|
|
||||||
let result = null;
|
|
||||||
messageListener(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_RPC",
|
|
||||||
method: "eth_accounts",
|
|
||||||
params: [],
|
|
||||||
origin: claimedOrigin,
|
|
||||||
},
|
|
||||||
sender,
|
|
||||||
(r) => {
|
|
||||||
result = r;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("a request is attributed to the frame that sent it", () => {
|
|
||||||
test("a stranger's frame on a connected site gets no address", async () => {
|
|
||||||
const accounts = loadBackground();
|
|
||||||
|
|
||||||
const result = await accounts({
|
|
||||||
url: STRANGER_ORIGIN + "/frame.html",
|
|
||||||
tab: { url: CONNECTED_ORIGIN + "/" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a connected site's frame on a stranger's page gets the address", async () => {
|
|
||||||
const accounts = loadBackground();
|
|
||||||
|
|
||||||
const result = await accounts({
|
|
||||||
url: CONNECTED_ORIGIN + "/frame.html",
|
|
||||||
tab: { url: STRANGER_ORIGIN + "/" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ result: [ADDRESS] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a request the browser does not say the sender of", () => {
|
|
||||||
test("is refused, whatever the tab or the message says", async () => {
|
|
||||||
const accounts = loadBackground();
|
|
||||||
|
|
||||||
const result = await accounts(
|
|
||||||
{ tab: { url: CONNECTED_ORIGIN + "/" } },
|
|
||||||
CONNECTED_ORIGIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
error: {
|
|
||||||
code: 4100,
|
|
||||||
message:
|
|
||||||
"The wallet could not tell which site sent this request.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -66,7 +66,6 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
status: 200,
|
status: 200,
|
||||||
json: async () => mockExplorer.items,
|
json: async () => mockExplorer.items,
|
||||||
})),
|
})),
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
setRuntimeDebug: () => {},
|
||||||
isDebug: () => false,
|
isDebug: () => false,
|
||||||
}));
|
}));
|
||||||
@@ -233,7 +232,7 @@ async function confirmSend(amount, token = "ETH") {
|
|||||||
async function approveTxWithFeePerGas(maxFeePerGas) {
|
async function approveTxWithFeePerGas(maxFeePerGas) {
|
||||||
approvalDetails = {
|
approvalDetails = {
|
||||||
type: "tx",
|
type: "tx",
|
||||||
origin: "https://dapp.example",
|
hostname: "dapp.example",
|
||||||
approvedFrom: HOLDER,
|
approvedFrom: HOLDER,
|
||||||
approvedTx: {
|
approvedTx: {
|
||||||
to: RECIPIENT,
|
to: RECIPIENT,
|
||||||
|
|||||||
@@ -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.");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// What reaches the console when an endpoint check in Settings fails.
|
|
||||||
//
|
|
||||||
// fetch refuses a URL with a user name and password in it, or one it cannot
|
|
||||||
// parse, with an error whose message carries the whole URL: the password, and
|
|
||||||
// any API key in the path or query string. The checks behind the RPC and
|
|
||||||
// Blockscout Save buttons printed that message
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410); they now name the
|
|
||||||
// endpoint by its origin.
|
|
||||||
//
|
|
||||||
// The real fetch runs; it throws before making any request. Debug mode is on,
|
|
||||||
// so every log level is printed.
|
|
||||||
|
|
||||||
const SECRETS = ["SECRETPASS789", "PATHKEY123", "QUERYTOKEN456"];
|
|
||||||
const RPC_WITH_PASSWORD =
|
|
||||||
"https://user:SECRETPASS789@rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
|
|
||||||
// Port 99999 is out of range, so the URL does not parse.
|
|
||||||
const RPC_UNPARSEABLE =
|
|
||||||
"https://rpc.example.invalid:99999/v3/PATHKEY123?token=QUERYTOKEN456";
|
|
||||||
const BLOCKSCOUT_WITH_PASSWORD =
|
|
||||||
"https://user:SECRETPASS789@explorer.example.invalid/PATHKEY123/api/v2";
|
|
||||||
|
|
||||||
const SAVED_RPC = "https://saved-rpc.example.invalid";
|
|
||||||
const SAVED_BLOCKSCOUT = "https://saved-explorer.example.invalid/api/v2";
|
|
||||||
|
|
||||||
let elements;
|
|
||||||
let flashes;
|
|
||||||
let printed;
|
|
||||||
let state;
|
|
||||||
|
|
||||||
// A stand-in for one DOM node: enough of an element for init() to set
|
|
||||||
// properties on it and hang listeners off it.
|
|
||||||
function fakeElement() {
|
|
||||||
return {
|
|
||||||
value: "",
|
|
||||||
checked: false,
|
|
||||||
textContent: "",
|
|
||||||
href: "",
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: { add() {}, remove() {} },
|
|
||||||
listeners: {},
|
|
||||||
addEventListener(event, handler) {
|
|
||||||
this.listeners[event] = handler;
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function element(id) {
|
|
||||||
return (elements[id] ||= fakeElement());
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSettingsView() {
|
|
||||||
elements = {};
|
|
||||||
flashes = [];
|
|
||||||
|
|
||||||
jest.resetModules();
|
|
||||||
|
|
||||||
jest.doMock("../src/popup/views/helpers", () => ({
|
|
||||||
$: element,
|
|
||||||
showView: () => {},
|
|
||||||
updateDebugBanner: () => {},
|
|
||||||
showFlash: (msg) => flashes.push(msg),
|
|
||||||
escapeHtml: (s) => s,
|
|
||||||
flashCopyFeedback: () => {},
|
|
||||||
goBack: () => {},
|
|
||||||
pushCurrentView: () => {},
|
|
||||||
onViewLeave: () => {},
|
|
||||||
VIEWS: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
state = require("../src/shared/state").state;
|
|
||||||
state.rpcUrl = SAVED_RPC;
|
|
||||||
state.blockscoutUrl = SAVED_BLOCKSCOUT;
|
|
||||||
require("../src/shared/log").setRuntimeDebug(true);
|
|
||||||
|
|
||||||
require("../src/popup/views/settings").init({});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(fieldId, buttonId, typed) {
|
|
||||||
element(fieldId).value = typed;
|
|
||||||
await element(buttonId).listeners.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The check failed, nothing was saved, and nothing printed carries the
|
|
||||||
// password or the key.
|
|
||||||
function expectFailedWithoutSecrets(label) {
|
|
||||||
expect(flashes).toContain("Could not reach endpoint.");
|
|
||||||
expect(state.rpcUrl).toBe(SAVED_RPC);
|
|
||||||
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
|
|
||||||
const line = printed.find((text) => text.includes(label));
|
|
||||||
expect(line).toBeDefined();
|
|
||||||
const all = printed.join("\n");
|
|
||||||
for (const secret of SECRETS) {
|
|
||||||
expect(all).not.toContain(secret);
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
printed = [];
|
|
||||||
for (const method of ["log", "warn", "error"]) {
|
|
||||||
jest.spyOn(console, method).mockImplementation((...args) => {
|
|
||||||
printed.push(args.map(String).join(" "));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
globalThis.chrome = {
|
|
||||||
runtime: { sendMessage: () => {} },
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.dontMock("../src/popup/views/helpers");
|
|
||||||
delete globalThis.chrome;
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetch puts the whole URL in the error it throws for such a URL", async () => {
|
|
||||||
for (const url of [RPC_WITH_PASSWORD, RPC_UNPARSEABLE]) {
|
|
||||||
const error = await fetch(url).catch((e) => e);
|
|
||||||
expect(error.message).toContain("PATHKEY123");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the RPC check of a URL with a user name and password", async () => {
|
|
||||||
loadSettingsView();
|
|
||||||
await save("settings-rpc", "btn-save-rpc", RPC_WITH_PASSWORD);
|
|
||||||
const line = expectFailedWithoutSecrets("RPC validation fetch failed");
|
|
||||||
expect(line).toContain("https://rpc.example.invalid");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the RPC check of a URL that does not parse", async () => {
|
|
||||||
loadSettingsView();
|
|
||||||
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
|
|
||||||
expectFailedWithoutSecrets("RPC validation fetch failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the Blockscout check of a URL with a user name and password", async () => {
|
|
||||||
loadSettingsView();
|
|
||||||
await save(
|
|
||||||
"settings-blockscout",
|
|
||||||
"btn-save-blockscout",
|
|
||||||
BLOCKSCOUT_WITH_PASSWORD,
|
|
||||||
);
|
|
||||||
const line = expectFailedWithoutSecrets("Blockscout validation failed");
|
|
||||||
expect(line).toContain("https://explorer.example.invalid");
|
|
||||||
});
|
|
||||||
+31
-110
@@ -270,32 +270,31 @@ describe("background refresh racing a wallet deleted on another page", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. Mutated in place
|
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
|
||||||
// from two different contexts — rememberSiteChoice() in
|
// from two different contexts — src/background/index.js:592-599 pushes a
|
||||||
// src/background/index.js pushes a newly approved origin onto
|
// newly approved hostname onto state.allowedSites[activeAddress], and the
|
||||||
// state.allowedSites[activeAddress], and the Settings "revoke" button
|
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
|
||||||
// (forgetOrigin() in src/popup/views/settings.js) filters an origin out of
|
// hostname out of state[key][addr] in place, deleting the address key
|
||||||
// state[key][addr] in place, deleting the address key entirely once its list
|
// entirely once its list is empty — the exact membership-vs-whole-field
|
||||||
// is empty — the exact membership-vs-whole-field pattern that made the
|
// pattern that made the whole-field `wallets` diff unsafe, on a
|
||||||
// whole-field `wallets` diff unsafe, on a security-relevant field: a stale
|
// security-relevant field: a stale whole-field save here can resurrect a
|
||||||
// whole-field save here can resurrect a revoked permission or wipe a freshly
|
// revoked permission or wipe a freshly granted one.
|
||||||
// granted one.
|
|
||||||
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||||
|
|
||||||
function approveSite(pageState, address, origin) {
|
function approveSite(pageState, address, hostname) {
|
||||||
if (!pageState.allowedSites[address]) {
|
if (!pageState.allowedSites[address]) {
|
||||||
pageState.allowedSites[address] = [];
|
pageState.allowedSites[address] = [];
|
||||||
}
|
}
|
||||||
if (!pageState.allowedSites[address].includes(origin)) {
|
if (!pageState.allowedSites[address].includes(hostname)) {
|
||||||
pageState.allowedSites[address].push(origin);
|
pageState.allowedSites[address].push(hostname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeSite(pageState, origin) {
|
function revokeSite(pageState, hostname) {
|
||||||
for (const addr of Object.keys(pageState.allowedSites)) {
|
for (const addr of Object.keys(pageState.allowedSites)) {
|
||||||
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
|
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
|
||||||
(o) => o !== origin,
|
(h) => h !== hostname,
|
||||||
);
|
);
|
||||||
if (pageState.allowedSites[addr].length === 0) {
|
if (pageState.allowedSites[addr].length === 0) {
|
||||||
delete pageState.allowedSites[addr];
|
delete pageState.allowedSites[addr];
|
||||||
@@ -309,7 +308,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
|
|||||||
await storage.set({
|
await storage.set({
|
||||||
autistmask: {
|
autistmask: {
|
||||||
wallets: [W1],
|
wallets: [W1],
|
||||||
allowedSites: { [ADDR2]: ["https://other.example"] },
|
allowedSites: { [ADDR2]: ["other.example"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,24 +318,24 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
|
|||||||
await settings.state.loadState();
|
await settings.state.loadState();
|
||||||
|
|
||||||
// A dApp approval window, opened later, approves a new site for a
|
// A dApp approval window, opened later, approves a new site for a
|
||||||
// different address and saves — the real sequence in
|
// different address and saves — the real sequence at
|
||||||
// rememberSiteChoice(), src/background/index.js.
|
// src/background/index.js:592-599.
|
||||||
const approval = loadPage(storage);
|
const approval = loadPage(storage);
|
||||||
await approval.state.loadState();
|
await approval.state.loadState();
|
||||||
approveSite(approval.state.state, ADDR1, "https://dapp.example");
|
approveSite(approval.state.state, ADDR1, "dapp.example");
|
||||||
await approval.state.saveState();
|
await approval.state.saveState();
|
||||||
expect(
|
expect(
|
||||||
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
||||||
).toEqual(["https://dapp.example"]);
|
).toEqual(["dapp.example"]);
|
||||||
|
|
||||||
// Settings revokes its own, unrelated site — the real sequence in
|
// Settings revokes its own, unrelated site — the real sequence at
|
||||||
// forgetOrigin(), src/popup/views/settings.js — and saves from state
|
// src/popup/views/settings.js:55-68 — and saves from state loaded
|
||||||
// loaded before the dApp approval ever happened.
|
// before the dApp approval ever happened.
|
||||||
revokeSite(settings.state.state, "https://other.example");
|
revokeSite(settings.state.state, "other.example");
|
||||||
await settings.state.saveState();
|
await settings.state.saveState();
|
||||||
|
|
||||||
const persisted = (await storage.get("autistmask")).autistmask;
|
const persisted = (await storage.get("autistmask")).autistmask;
|
||||||
expect(persisted.allowedSites[ADDR1]).toEqual(["https://dapp.example"]);
|
expect(persisted.allowedSites[ADDR1]).toEqual(["dapp.example"]);
|
||||||
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
|
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -347,7 +346,7 @@ describe("a revoked site permission against a stale page's later save", () => {
|
|||||||
await storage.set({
|
await storage.set({
|
||||||
autistmask: {
|
autistmask: {
|
||||||
wallets: [W1],
|
wallets: [W1],
|
||||||
allowedSites: { [ADDR1]: ["https://evil.example"] },
|
allowedSites: { [ADDR1]: ["evil.example"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -355,24 +354,23 @@ describe("a revoked site permission against a stale page's later save", () => {
|
|||||||
const stale = loadPage(storage);
|
const stale = loadPage(storage);
|
||||||
await stale.state.loadState();
|
await stale.state.loadState();
|
||||||
|
|
||||||
// Settings revokes it — forgetOrigin(), src/popup/views/settings.js —
|
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
|
||||||
// from a second page.
|
// second page.
|
||||||
const settings = loadPage(storage);
|
const settings = loadPage(storage);
|
||||||
await settings.state.loadState();
|
await settings.state.loadState();
|
||||||
revokeSite(settings.state.state, "https://evil.example");
|
revokeSite(settings.state.state, "evil.example");
|
||||||
await settings.state.saveState();
|
await settings.state.saveState();
|
||||||
expect(
|
expect(
|
||||||
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
||||||
// The stale page, unaware of the revoke, approves an unrelated site
|
// The stale page, unaware of the revoke, approves an unrelated site
|
||||||
// for a different address and saves — rememberSiteChoice(),
|
// for a different address and saves — src/background/index.js:592-599.
|
||||||
// src/background/index.js.
|
approveSite(stale.state.state, ADDR2, "good.example");
|
||||||
approveSite(stale.state.state, ADDR2, "https://good.example");
|
|
||||||
await stale.state.saveState();
|
await stale.state.saveState();
|
||||||
|
|
||||||
const persisted = (await storage.get("autistmask")).autistmask;
|
const persisted = (await storage.get("autistmask")).autistmask;
|
||||||
expect(persisted.allowedSites[ADDR2]).toEqual(["https://good.example"]);
|
expect(persisted.allowedSites[ADDR2]).toEqual(["good.example"]);
|
||||||
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
|
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -423,80 +421,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",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-170
@@ -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.
|
||||||
@@ -334,9 +167,7 @@ describe("an unversioned profile that is perfectly valid", () => {
|
|||||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||||
expect(stored.activeAddress).toBe(ADDRESS);
|
expect(stored.activeAddress).toBe(ADDRESS);
|
||||||
expect(stored.allowedSites).toEqual({
|
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,30 +20,6 @@ const path = require("path");
|
|||||||
|
|
||||||
const { makeStorageStub } = require("./storageStub");
|
const { makeStorageStub } = require("./storageStub");
|
||||||
|
|
||||||
// The four libraries the popup loads from node_modules, loaded once per test
|
|
||||||
// file and handed to every boot. jest.resetModules() in bootPopup() empties the
|
|
||||||
// module cache but keeps what jest.doMock() registered, so these registrations
|
|
||||||
// hold for every boot in the file and the libraries are not loaded again. None
|
|
||||||
// of them holds popup state; everything under src/ is still loaded fresh on
|
|
||||||
// each boot.
|
|
||||||
//
|
|
||||||
// A test's own mock of one of them: a jest.doMock() made inside the test
|
|
||||||
// replaces the registration here, as it would for any module. A top-of-file
|
|
||||||
// jest.mock() is what the require() below gets, so it is kept, but its factory
|
|
||||||
// runs once per file and every boot shares the same mock object.
|
|
||||||
const ethers = require("ethers");
|
|
||||||
const sodium = require("libsodium-wrappers-sumo");
|
|
||||||
const QRCode = require("qrcode");
|
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
|
||||||
jest.doMock("ethers", () => ethers);
|
|
||||||
jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
|
||||||
jest.doMock("qrcode", () => QRCode);
|
|
||||||
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",
|
||||||
@@ -75,7 +51,7 @@ function unversionedValidProfile(extra) {
|
|||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||||
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||||
allowedSites: { [ADDRESS]: ["https://dapp.example"] },
|
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -253,6 +229,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 +239,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 +272,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 +296,8 @@ async function bootPopup(stored, options) {
|
|||||||
}
|
}
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
|
globalThis.setInterval = realSetInterval;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storage,
|
storage,
|
||||||
document,
|
document,
|
||||||
@@ -351,12 +317,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 +334,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 = {
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
errorf: () => {},
|
errorf: () => {},
|
||||||
},
|
},
|
||||||
debugFetch: jest.fn(),
|
debugFetch: jest.fn(),
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
setRuntimeDebug: () => {},
|
||||||
isDebug: () => false,
|
isDebug: () => false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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
@@ -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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ async function openSignScreen(data) {
|
|||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||||
reply({
|
reply({
|
||||||
type: "sign",
|
type: "sign",
|
||||||
origin: "https://dapp.example",
|
hostname: "dapp.example",
|
||||||
isPhishingDomain: false,
|
isPhishingDomain: false,
|
||||||
approvedFrom: OWNER,
|
approvedFrom: OWNER,
|
||||||
signParams: request(data),
|
signParams: request(data),
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|||||||
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||||
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||||
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
|
||||||
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
|
||||||
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
// AutistMask's first-ever swap, 2026-02-27.
|
// AutistMask's first-ever swap, 2026-02-27.
|
||||||
@@ -77,14 +75,6 @@ function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09)
|
|
||||||
function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
|
|
||||||
return coder.encode(
|
|
||||||
["address", "uint256", "uint256", "address[]", "bool"],
|
|
||||||
[recipient, amountOut, amountInMax, pathAddrs, true],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
|
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
|
||||||
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
|
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
|
||||||
// V3 path: token(20) + fee(3) + token(20) ...
|
// V3 path: token(20) + fee(3) + token(20) ...
|
||||||
@@ -233,233 +223,6 @@ describe("uniswap decoder", () => {
|
|||||||
expect(minOut.value).toContain("WETH");
|
expect(minOut.value).toContain("WETH");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buy exactly 0.5 WETH for at most 1,500 USDC, paid by the user, sent to
|
|
||||||
// the caller (the router's MSG_SENDER recipient, address(1)).
|
|
||||||
test("decodes V2_SWAP_EXACT_OUT, stating the input amount as a maximum", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
"0x09", // V2_SWAP_EXACT_OUT
|
|
||||||
[
|
|
||||||
encodeV2SwapExactOut(
|
|
||||||
"0x0000000000000000000000000000000000000001",
|
|
||||||
500000000000000000n, // amountOut: 0.5 WETH
|
|
||||||
1500000000n, // amountInMax: 1,500 USDC (6 decimals)
|
|
||||||
[USDC_ADDR, WETH_ADDR],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
1767225600n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result.name).toBe("Swap USDC → WETH");
|
|
||||||
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
|
|
||||||
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
|
|
||||||
|
|
||||||
// The wait, success and error screens show rawValue as the amount, so
|
|
||||||
// it says "Up to" as well.
|
|
||||||
const amount = detail(result, "Amount");
|
|
||||||
expect(amount.value).toBe("Up to 1500.0000 USDC");
|
|
||||||
expect(amount.rawValue).toBe("Up to 1500.0000");
|
|
||||||
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Buy exactly 1,500 USDC for at most 0.5 ETH: WRAP_ETH of the maximum, the
|
|
||||||
// swap, then UNWRAP_WETH of 0, which returns the ETH the swap did not spend.
|
|
||||||
test("a V2 exact-out swap paid in ETH shows the token it buys and a maximum", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x09, 0x0c]),
|
|
||||||
[
|
|
||||||
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
|
|
||||||
encodeV2SwapExactOut(
|
|
||||||
USER_ADDR,
|
|
||||||
1500000000n, // amountOut: 1,500 USDC
|
|
||||||
500000000000000000n, // amountInMax: 0.5 WETH
|
|
||||||
[WETH_ADDR, USDC_ADDR],
|
|
||||||
),
|
|
||||||
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result.name).toBe("Swap ETH → USDC");
|
|
||||||
|
|
||||||
const amount = detail(result, "Amount");
|
|
||||||
expect(amount.value).toBe("Up to 0.5000 ETH");
|
|
||||||
expect(amount.rawValue).toBe("Up to 0.5000");
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
||||||
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Buy exactly 0.5 ETH for at most 1,500 USDC under a permit: the swap buys
|
|
||||||
// WETH and UNWRAP_WETH turns it into ETH.
|
|
||||||
test("a V2 exact-out swap that buys ETH shows ETH and the permit as a maximum", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
|
|
||||||
[
|
|
||||||
encodePermit2(USDC_ADDR, 1500000000n, ROUTER_ADDR),
|
|
||||||
encodeV2SwapExactOut(
|
|
||||||
ROUTER_ADDR,
|
|
||||||
500000000000000000n, // amountOut: 0.5 WETH
|
|
||||||
1500000000n, // amountInMax: 1,500 USDC
|
|
||||||
[USDC_ADDR, WETH_ADDR],
|
|
||||||
),
|
|
||||||
encodeWrapEth(USER_ADDR, 500000000000000000n), // UNWRAP_WETH
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result.name).toBe("Swap USDC → ETH");
|
|
||||||
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
|
|
||||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Paid in a token, an UNWRAP_WETH of 0 after a swap that buys something
|
|
||||||
// other than WETH leaves the output side as it is: Token Out and Min.
|
|
||||||
// received are the token bought and its figure, not ETH.
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
paidIn: "WETH",
|
|
||||||
tokenIn: WETH_ADDR,
|
|
||||||
amountInMax: 500000000000000000n, // 0.5 WETH
|
|
||||||
tokenOut: USDC_ADDR,
|
|
||||||
amountOut: 1300000000n, // 1,300 USDC
|
|
||||||
minReceived: "1300.0000 USDC",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
paidIn: "USDC",
|
|
||||||
tokenIn: USDC_ADDR,
|
|
||||||
amountInMax: 8000000n, // 8 USDC
|
|
||||||
tokenOut: DAI_ADDR,
|
|
||||||
amountOut: 7000000000000000000n, // 7 DAI
|
|
||||||
minReceived: "7.0000 DAI",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"a V2 exact-out swap paid in $paidIn, then UNWRAP_WETH, shows the token it buys",
|
|
||||||
({ tokenIn, amountInMax, tokenOut, amountOut, minReceived }) => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
|
|
||||||
[
|
|
||||||
encodePermit2(tokenIn, amountInMax, ROUTER_ADDR),
|
|
||||||
encodeV2SwapExactOut(USER_ADDR, amountOut, amountInMax, [
|
|
||||||
tokenIn,
|
|
||||||
tokenOut,
|
|
||||||
]),
|
|
||||||
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(detail(result, "Token Out").address).toBe(tokenOut);
|
|
||||||
expect(detail(result, "Min. received").value).toBe(minReceived);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// An exact-in swap is held to the same rule: buying USDC, then UNWRAP_WETH,
|
|
||||||
// receives USDC.
|
|
||||||
test("an exact-in swap to a token other than WETH, then UNWRAP_WETH, shows that token", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
|
||||||
[
|
|
||||||
encodeV2SwapExactIn(USER_ADDR, 2000000n, 1900000n, [
|
|
||||||
USDT_ADDR,
|
|
||||||
USDC_ADDR,
|
|
||||||
]),
|
|
||||||
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result.name).toBe("Swap USDT → USDC");
|
|
||||||
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
||||||
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
|
|
||||||
// UNWRAP_WETH makes the output ETH.
|
|
||||||
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(
|
|
||||||
["uint8", "uint8", "uint8", "uint8"],
|
|
||||||
[0x0b, 0x09, 0x08, 0x0c],
|
|
||||||
),
|
|
||||||
[
|
|
||||||
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
|
|
||||||
encodeV2SwapExactOut(
|
|
||||||
ROUTER_ADDR,
|
|
||||||
1500000000n, // amountOut: 1,500 USDC
|
|
||||||
500000000000000000n, // amountInMax: 0.5 WETH
|
|
||||||
[WETH_ADDR, USDC_ADDR],
|
|
||||||
),
|
|
||||||
encodeV2SwapExactIn(
|
|
||||||
ROUTER_ADDR,
|
|
||||||
1500000000n, // amountIn: 1,500 USDC
|
|
||||||
400000000000000000n, // amountOutMin: 0.4 WETH
|
|
||||||
[USDC_ADDR, WETH_ADDR],
|
|
||||||
),
|
|
||||||
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
|
|
||||||
test("a swap to Sepolia WETH, then UNWRAP_WETH, shows ETH", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
|
||||||
[
|
|
||||||
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
||||||
USDC_ADDR,
|
|
||||||
SEPOLIA_WETH_ADDR,
|
|
||||||
]),
|
|
||||||
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// A step that states a Min. received figure but names no output token has
|
|
||||||
// set the output side, so UNWRAP_WETH does not make it ETH: nothing says
|
|
||||||
// the figure is counted in WETH.
|
|
||||||
test("a step with a minimum but no output token, then UNWRAP_WETH, names no token", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
|
|
||||||
[
|
|
||||||
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
|
|
||||||
encodeV4ExactIn(
|
|
||||||
USDC_ADDR,
|
|
||||||
[], // no path: this step names no output currency
|
|
||||||
1000000000n, // 1,000 USDC
|
|
||||||
400000000000000000n, // amountOutMin
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
|
||||||
"Unknown (not named in the calldata)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Min. received").value).toBe(
|
|
||||||
"400000000000000000 base units (decimals unknown)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
|
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
"0x00", // V3_SWAP_EXACT_IN
|
"0x00", // V3_SWAP_EXACT_IN
|
||||||
@@ -554,33 +317,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 +594,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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -382,56 +382,8 @@ describe("a scale the explorer's own rows disagree about", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/377. The Send screen read the
|
|
||||||
// stored balance and said "5.0000 NOVEL", the confirmation screen it leads
|
|
||||||
// to said "unknown (NOVEL)", and the fee message asked the user to go back
|
|
||||||
// and try again, which cannot supply a scale.
|
|
||||||
test("reads the same on the Send screen and the confirmation screen, and the fee message names the scale", async () => {
|
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
|
||||||
state.selectedToken = NOVEL;
|
|
||||||
send.updateSendBalance();
|
|
||||||
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
|
|
||||||
|
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
|
||||||
confirmTx.show(txInfo);
|
|
||||||
await settle();
|
|
||||||
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
|
||||||
expect(text("confirm-fee-unknown-error")).toBe(
|
|
||||||
"The network fee could not be estimated, because this wallet" +
|
|
||||||
" does not know how many decimal places this token uses, so" +
|
|
||||||
" this transaction cannot be sent.",
|
|
||||||
);
|
|
||||||
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
|
|
||||||
"visible",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("while a fee that fails for any other reason keeps its retry", async () => {
|
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
|
||||||
const getFeeData = mockProvider.getFeeData;
|
|
||||||
mockProvider.getFeeData = async () => {
|
|
||||||
throw new Error("the node did not answer");
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
confirmTx.show(txInfo);
|
|
||||||
await settle();
|
|
||||||
} finally {
|
|
||||||
mockProvider.getFeeData = getFeeData;
|
|
||||||
}
|
|
||||||
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
|
|
||||||
expect(text("confirm-fee-unknown-error")).toBe(
|
|
||||||
"The network fee could not be estimated, so this transaction" +
|
|
||||||
" cannot be checked against your balance. Please go back and" +
|
|
||||||
" try again.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("while agreeing rows leave the scale usable", async () => {
|
test("while agreeing rows leave the scale usable", async () => {
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
||||||
state.selectedToken = NOVEL;
|
|
||||||
send.updateSendBalance();
|
|
||||||
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
|
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
expect(txInfo.tokenDecimals).toBe(6);
|
expect(txInfo.tokenDecimals).toBe(6);
|
||||||
expect(txInfo.tokenBalance).toBe("5.0");
|
expect(txInfo.tokenBalance).toBe("5.0");
|
||||||
|
|||||||
+2
-294
@@ -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
|
||||||
|
|||||||
+11
-20
@@ -28,14 +28,11 @@ function makeState(overrides = {}) {
|
|||||||
selectedAddress: 0,
|
selectedAddress: 0,
|
||||||
activeAddress: A0,
|
activeAddress: A0,
|
||||||
allowedSites: {
|
allowedSites: {
|
||||||
[A0]: ["https://a.example"],
|
[A0]: ["a.example"],
|
||||||
[A1]: ["https://b.example"],
|
[A1]: ["b.example"],
|
||||||
[B0]: ["https://c.example"],
|
[B0]: ["c.example"],
|
||||||
},
|
|
||||||
deniedSites: {
|
|
||||||
[A1]: ["https://d.example"],
|
|
||||||
[C0]: ["https://e.example"],
|
|
||||||
},
|
},
|
||||||
|
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,7 +41,7 @@ describe("removeWalletFromState", () => {
|
|||||||
test("deleting the last wallet clears hasWallet", () => {
|
test("deleting the last wallet clears hasWallet", () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
wallets: [wallet("A", [A0])],
|
wallets: [wallet("A", [A0])],
|
||||||
allowedSites: { [A0]: ["https://a.example"] },
|
allowedSites: { [A0]: ["a.example"] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,8 +109,8 @@ describe("removeWalletFromState", () => {
|
|||||||
|
|
||||||
removeWalletFromState(state, 0);
|
removeWalletFromState(state, 0);
|
||||||
|
|
||||||
expect(state.allowedSites).toEqual({ [B0]: ["https://c.example"] });
|
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
|
||||||
expect(state.deniedSites).toEqual({ [C0]: ["https://e.example"] });
|
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,14 +126,8 @@ function makeAddressState(overrides = {}) {
|
|||||||
selectedWallet: 0,
|
selectedWallet: 0,
|
||||||
selectedAddress: 0,
|
selectedAddress: 0,
|
||||||
activeAddress: A0,
|
activeAddress: A0,
|
||||||
allowedSites: {
|
allowedSites: { [A0]: ["a.example"], [A1]: ["b.example"] },
|
||||||
[A0]: ["https://a.example"],
|
deniedSites: { [A1]: ["d.example"], [B0]: ["e.example"] },
|
||||||
[A1]: ["https://b.example"],
|
|
||||||
},
|
|
||||||
deniedSites: {
|
|
||||||
[A1]: ["https://d.example"],
|
|
||||||
[B0]: ["https://e.example"],
|
|
||||||
},
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -282,8 +273,8 @@ describe("removeAddressFromState", () => {
|
|||||||
|
|
||||||
removeAddressFromState(state, 0, 1);
|
removeAddressFromState(state, 0, 1);
|
||||||
|
|
||||||
expect(state.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
expect(state.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||||
expect(state.deniedSites).toEqual({ [B0]: ["https://e.example"] });
|
expect(state.deniedSites).toEqual({ [B0]: ["e.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The derivation counter is a high-water mark, never rewound: "+" derives
|
// The derivation counter is a high-water mark, never rewound: "+" derives
|
||||||
|
|||||||
Reference in New Issue
Block a user