Compare commits
15
Commits
c6d3890af1
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a207ac70bd | ||
|
|
cf7ca99215 | ||
|
|
90a9d5597f | ||
|
|
6a86b726d2 | ||
|
|
18bdafd130 | ||
|
|
8c8caafe33 | ||
|
|
6c885a0c05 | ||
|
|
f86740ce69 | ||
|
|
e3790c5da4 | ||
|
|
2b97aae04a | ||
|
|
6127fd9432 | ||
|
|
f4a51e1679 | ||
|
|
375998beaf | ||
|
|
3b713809c8 | ||
|
|
8ac2c87c2c |
@@ -22,11 +22,10 @@ on: [push]
|
|||||||
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
|
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
|
||||||
# Gitea branch protection, which this repo does not configure, so a failure
|
# Gitea branch protection, which this repo does not configure, so a failure
|
||||||
# here is a red mark a reviewer has to account for rather than a hard
|
# here is a red mark a reviewer has to account for rather than a hard
|
||||||
# block. Making e2e-chrome a required check is blocked on the measured
|
# block. Making e2e-chrome a required check is blocked while reports of the
|
||||||
# flake in the dApp signing wait -- two of six runs of unmutated code on a
|
# Chrome suite failing under load are still open; the "In CI" section of
|
||||||
# loaded machine -- tracked as
|
# README.md names them. A gate that fails at random teaches people to merge
|
||||||
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
|
# past red.
|
||||||
# random teaches people to merge past red.
|
|
||||||
#
|
#
|
||||||
# Nothing here may pass vacuously. There is no continue-on-error and no
|
# Nothing here may pass vacuously. There is no continue-on-error and no
|
||||||
# `|| true`. Both scripts exit non-zero when docker is missing, when the
|
# `|| true`. Both scripts exit non-zero when docker is missing, when the
|
||||||
|
|||||||
@@ -619,14 +619,13 @@ 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. The Chrome suite is
|
That is not only a statement about configuration. A report of the Chrome suite
|
||||||
**measurably flaky under load** — two of six runs of unmutated code on a busy
|
**failing under load** is still open:
|
||||||
machine lost the approval popup out from under the dApp signing wait, always in
|
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
|
||||||
the `#183` section, tracked as
|
failing with `the extension opened no approval window within 30000ms`. So a red
|
||||||
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
|
`e2e-chrome` has to be read before it is believed, and those failures are the
|
||||||
has to be read before it is believed, and that flake is the blocker to ever
|
blocker to ever making this a required check. Do not answer them with a retry
|
||||||
making this a required check. Do not answer it with a retry wrapper: a suite
|
wrapper: a suite that reruns until it is green stops being evidence.
|
||||||
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
|
||||||
@@ -695,6 +694,7 @@ 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)
|
||||||
@@ -846,6 +846,20 @@ wherever shown. If a formatting rule applies in one place, it applies in every
|
|||||||
place. Users should never see the same value rendered differently on two
|
place. Users should never see the same value rendered differently on two
|
||||||
screens.
|
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
|
||||||
due to display size constraints. Wherever possible, and, notably, **in all
|
due to display size constraints. Wherever possible, and, notably, **in all
|
||||||
@@ -887,7 +901,9 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
|
|||||||
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
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.
|
shows is a V4 exact-in `amountIn` of zero and
|
||||||
|
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
|
||||||
|
exact-in `amountIn` of zero.
|
||||||
|
|
||||||
The rule and its exception live in `src/shared/amountDisplay.js` as
|
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
|
||||||
@@ -926,7 +942,10 @@ rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
|
|||||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
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`.
|
still shown as `Unlimited`. A source's answer counts only if it is a whole
|
||||||
|
number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
|
||||||
|
format more than 80 decimal places, so a token that reports 81 to 255 is shown
|
||||||
|
as one whose scale nothing knows.
|
||||||
|
|
||||||
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
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
|
||||||
@@ -977,7 +996,8 @@ read:
|
|||||||
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
||||||
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
||||||
not necessarily all of it; the wait, success and error screens show it with
|
not necessarily all of it; the wait, success and error screens show it with
|
||||||
the same words. `Unlimited` and `All available (V4 open delta)` keep their
|
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
|
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
||||||
`amountOut`, the exact amount it buys.
|
`amountOut`, the exact amount it buys.
|
||||||
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
||||||
@@ -988,11 +1008,22 @@ read:
|
|||||||
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
||||||
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
||||||
exact-in action.
|
exact-in action.
|
||||||
|
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
|
||||||
|
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
|
||||||
|
The router reads that zero as "the pair already holds the input tokens": the
|
||||||
|
step pays nothing itself and swaps whatever an earlier step sent to the pair,
|
||||||
|
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
||||||
|
such meaning and is shown as a zero.
|
||||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
||||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
||||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
||||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
||||||
read `0.0000` when the token's scale was known.
|
read `0.0000` when the token's scale was known. The router passes a balance
|
||||||
|
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
||||||
|
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||||
|
output side holds no minimum, not even a zero one, at the point the check is
|
||||||
|
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||||
|
`minBalance` sets both lines, as a swap step does.
|
||||||
|
|
||||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
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
|
||||||
@@ -1005,6 +1036,12 @@ unwraps the WETH it did not spend shows USDC. The token permission warning on
|
|||||||
the signature screen has its own amount wording, including `Unknown`; the
|
the signature screen has its own amount wording, including `Unknown`; the
|
||||||
SignApproval section below describes it.
|
SignApproval section below describes it.
|
||||||
|
|
||||||
|
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
|
||||||
|
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
|
||||||
|
UTC, so a later deadline, such as the `uint256` maximum, reads
|
||||||
|
`After 275760-09-13 00:00:00 (no deadline in practice)` rather than leaving the
|
||||||
|
whole swap undecoded.
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
@@ -1083,15 +1120,21 @@ balance is nonzero and it is in the bundled known-token list, is tracked by the
|
|||||||
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
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"`). That filter is unconditional — the "Hide
|
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
|
||||||
|
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
|
||||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||||||
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
|
and the send-screen token selector, not this list. A token's holder count is
|
||||||
every nonzero holding of a token it admits, however small, but a holding below
|
unknown when the explorer reports none, or reports anything other than a whole
|
||||||
0.000001 is left out of the balance lists, the send-screen token selector, the
|
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
|
||||||
address total and the remove-address warning (`isBelowOneMillionth()` in
|
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
|
||||||
`src/shared/amountDisplay.js`). The Send and confirmation screens show it when
|
or more, so such a token is shown only when it is on the bundled list or
|
||||||
its token is the one being sent. Tracked tokens with a zero balance are listed
|
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
|
||||||
as well while "Show tracked tokens with zero balance" is on.
|
admits, however small, but a holding below 0.000001 is left out of the balance
|
||||||
|
lists, the send-screen token selector, the address total and the remove-address
|
||||||
|
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
|
||||||
|
confirmation screens show it when its token is the one being sent. Tracked
|
||||||
|
tokens with a zero balance are listed as well while "Show tracked tokens with
|
||||||
|
zero balance" is on.
|
||||||
|
|
||||||
#### Stored state and its version
|
#### Stored state and its version
|
||||||
|
|
||||||
@@ -1111,16 +1154,18 @@ 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()` for the popup and `getState()` for
|
bytes, before normalization: `loadState()` and every `saveState()` for the
|
||||||
the background. It refuses a record that is not an object, a `schemaVersion`
|
popup, and `getState()` for the background. It refuses a record that is not an
|
||||||
this build does not understand (a newer one included), a `wallets` that is not a
|
object, a `schemaVersion` this build does not understand (a newer one included),
|
||||||
list of wallet records with address records in them, and a `networkId` that is
|
a `wallets` that is not a list of wallet records with address records in them,
|
||||||
not a network in `src/shared/networks.js`. Refusing is the whole point — a
|
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
|
||||||
record the wallet cannot vouch for is never normalized, never written back, and
|
the whole point — a record the wallet cannot vouch for is never normalized,
|
||||||
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
|
never written back, and never half-loaded. The popup shows StateRecovery,
|
||||||
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
|
whether it finds the record unreadable when it opens or at a save while it is
|
||||||
saved data cannot be read and that nothing was signed or sent, rather than the
|
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
|
||||||
generic `-32603` every request used to answer.
|
spec leaves unassigned) saying the saved data cannot be read and that nothing
|
||||||
|
was signed or sent, rather than the generic `-32603` every request used to
|
||||||
|
answer.
|
||||||
|
|
||||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
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
|
||||||
@@ -1164,8 +1209,9 @@ 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, a record a newer build wrote — and the
|
covers — a quota, a revoked permission — and the wallet must never look healthy
|
||||||
wallet must never look healthy while that is true.
|
while that is true. A save that fails because the stored record fails the gate,
|
||||||
|
such as one a newer build wrote, gets StateRecovery instead of the banner.
|
||||||
|
|
||||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
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
|
||||||
@@ -1397,7 +1443,9 @@ 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
|
website where known. The "Holders:" row is left out, not shown as 0, when
|
||||||
|
the token's balance-list entry has no holder count: the explorer did not
|
||||||
|
report a readable one, or the token is not in the balance list
|
||||||
- Token-filtered transaction list (only this token's transfers)
|
- 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
|
||||||
@@ -1419,6 +1467,17 @@ view would leave a wallet one click from deletion.
|
|||||||
- Amount input with current balance display, which reads
|
- Amount input with current balance display, which reads
|
||||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||||
ConfirmTx's balance line does (see Unknown token scale)
|
ConfirmTx's balance line does (see Unknown token scale)
|
||||||
|
- "Max" button beside the amount input, always in place. It fills in a
|
||||||
|
token's balance, cut down to the 18 decimal places ConfirmTx accepts for a
|
||||||
|
token that has more, or for ETH the exact balance minus the network fee
|
||||||
|
reserve that ConfirmTx's balance check gates on, never the rounded balance
|
||||||
|
shown above it. The ETH fee is estimated for the recipient entered, so it
|
||||||
|
asks for a recipient first; an estimate that finishes after the screen was
|
||||||
|
left or the address, holding or recipient changed fills nothing in. Where
|
||||||
|
there is nothing to fill in, a flash message says why: the balance does
|
||||||
|
not cover the fee, the fee could not be estimated, or the token's balance
|
||||||
|
is unknown or zero. Typing in the amount makes it an ordinary amount;
|
||||||
|
changing what to send clears an amount Max filled in
|
||||||
- "Review" button, disabled until the recipient validates
|
- "Review" button, disabled until the recipient validates
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||||
@@ -1435,7 +1494,14 @@ 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)
|
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
||||||
|
filled in is worked out again from this screen's own fee estimate when it
|
||||||
|
arrives, as the balance minus the reserve, and the transaction is signed
|
||||||
|
with that estimate's fee fields, so a fee fetched again at signing cannot
|
||||||
|
exceed what the amount leaves behind. The address keeps whatever part of
|
||||||
|
the reserve the transaction does not use. If the balance no longer covers
|
||||||
|
the fee, the amount is left as it was and the amount-plus-fee error below
|
||||||
|
blocks the send
|
||||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
||||||
for a token whose scale is unknown
|
for a token whose scale is unknown
|
||||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||||
@@ -1563,7 +1629,9 @@ 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
|
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
|
||||||
|
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
|
||||||
|
the token reports.
|
||||||
- Status: "Success" or "Failed"
|
- 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
|
||||||
@@ -1737,7 +1805,10 @@ 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
|
- The wallet's name, and a text input asking for it to be typed back. A name
|
||||||
|
that shows nothing at all (only spaces, or only characters that paint
|
||||||
|
nothing) is shown as "Wallet N", its position in the list, and that is
|
||||||
|
what is typed back.
|
||||||
- Error line
|
- Error line
|
||||||
- "Delete This Wallet Forever" button
|
- "Delete This Wallet Forever" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
@@ -1745,9 +1816,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) → "That is not the name
|
- "Delete This Wallet Forever" (name does not match, or the field is empty)
|
||||||
of this wallet. Type <name> to confirm." on the error line, nothing
|
→ "That is not the name of this wallet. Type <name> to confirm." on
|
||||||
deleted
|
the error line, nothing 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
|
||||||
@@ -1756,8 +1827,13 @@ 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 with surrounding spaces and letter case
|
on, not a secret, so it is matched as the user can see it: letter case,
|
||||||
ignored.
|
surrounding spaces and repeated inner spaces are ignored, and characters that
|
||||||
|
paint nothing (format characters such as the zero-width space,
|
||||||
|
default-ignorable characters, and DELETE — the same set
|
||||||
|
`src/shared/symbolSpoof.js` strips) are removed from both sides before
|
||||||
|
comparing. An empty field, or one holding only spaces or such characters, is
|
||||||
|
refused whatever the wallet is called.
|
||||||
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
|
- 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.
|
||||||
|
|
||||||
@@ -1927,10 +2003,19 @@ view would leave a wallet one click from deletion.
|
|||||||
- 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: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
||||||
message fields (EIP-712 typed data). The primary type shown is the one
|
decode to as UTF-8, laid out left to right in the order of the bytes that
|
||||||
ethers signs, derived from the typed data's `types`, not the type the site
|
are signed, right-to-left characters included. Each control character,
|
||||||
states.
|
each line or paragraph separator (U+2028, U+2029; left in the text, a
|
||||||
|
paragraph separator would end that layout for the text after it), and each
|
||||||
|
character that paints nothing (format characters such as zero-width and
|
||||||
|
bidirectional ones, default-ignorable characters such as variation
|
||||||
|
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
|
||||||
|
mark instead of acting on the text; a line feed is shown as a line break.
|
||||||
|
Bytes that are not UTF-8 are shown as "This message is not text." For
|
||||||
|
typed data, formatted domain/type/message fields (EIP-712). The primary
|
||||||
|
type shown is the one ethers signs, derived from the typed data's `types`,
|
||||||
|
not the type the site states.
|
||||||
- Token permission warning, at the top of the message (typed data whose
|
- 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
|
||||||
@@ -1942,12 +2027,20 @@ 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
|
||||||
@@ -1958,9 +2051,19 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
#### StateRecovery (`state-recovery`)
|
#### StateRecovery (`state-recovery`)
|
||||||
|
|
||||||
- **When**: `loadState()` refused the stored profile, so the popup has no
|
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
|
||||||
profile at all. It is the only screen reached without one, and the only one
|
`loadState()` refusing it, so the popup has no profile at all. While the popup
|
||||||
that never appears during ordinary use.
|
is open, on any screen, it is a save refusing it: every `saveState()` reads
|
||||||
|
the stored record and runs the same check before writing, so the popup finds
|
||||||
|
it at the next navigation or ten-second refresh, whether or not the network
|
||||||
|
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
|
||||||
|
fails for any other reason, such as a storage read or write that errors, gets
|
||||||
|
the "NOT SAVED" banner instead and leaves the screen as it is. The screen it
|
||||||
|
replaces is left as any navigation leaves it, so a revealed phrase or key, or
|
||||||
|
a typed password, is wiped. Once up, the screen stays until the popup closes
|
||||||
|
or reloads: work still running in the popup, such as a transaction wait,
|
||||||
|
cannot replace it, even after the record is erased in another window. It is
|
||||||
|
the only screen that never appears during ordinary use.
|
||||||
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
- **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.
|
||||||
@@ -1987,16 +2090,20 @@ 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, and `showView()` is not used to raise it for the same
|
could not be read. `showView()` is not used to raise it, for the same reason:
|
||||||
reason — it reads and writes the state singleton.
|
it reads and writes the state singleton. Under an open popup the screen is
|
||||||
|
passed to `showView()` only to run the replaced screen's cleanup; from then on
|
||||||
|
`showView()` shows nothing else in that popup.
|
||||||
- **Both controls are required.** An export with no reset leaves the user
|
- **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`: it is never persisted as the current view, because
|
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
|
||||||
nothing on this path writes state at all.
|
can become readable again under an open popup, erased in another window, and
|
||||||
|
the next save from that popup then succeeds. A popup opened after that opens
|
||||||
|
normally.
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
@@ -2174,9 +2281,10 @@ 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
|
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
|
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
|
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
|
||||||
refresh and token lookup name the RPC endpoint by its origin too. The URL's path
|
refresh, the token lookup and a failed endpoint check in settings name the
|
||||||
and query string, where RPC providers put API keys, and the request body are
|
endpoint by its origin too. The URL's path and query string, where RPC providers
|
||||||
never logged.
|
put API keys, any user name and password in it, and the request body are never
|
||||||
|
logged.
|
||||||
|
|
||||||
### Key Decisions
|
### Key Decisions
|
||||||
|
|
||||||
@@ -2304,7 +2412,8 @@ 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 — `"ETH"`, the native asset, is the only one — may be borne by
|
contract at all — the native asset's labels: `"ETH"` and every network's
|
||||||
|
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
|
||||||
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
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.
|
||||||
@@ -2313,7 +2422,8 @@ 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.
|
the known token list. A transfer whose token's holder count is unknown (see
|
||||||
|
Data Model) is kept: only a reported count below 1,000 hides it.
|
||||||
|
|
||||||
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
|
- **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
|
||||||
@@ -2323,7 +2433,9 @@ 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.
|
via a fake Transfer event. A token whose holder count is unknown is kept in
|
||||||
|
the selector. The selector offers only tokens in the balance list, so such a
|
||||||
|
token is one on the bundled list or one the user tracks.
|
||||||
|
|
||||||
- **Dust transaction filtering**: A second wave of the same attack used real
|
- **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
|
||||||
@@ -2347,8 +2459,9 @@ 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). The dust
|
list's own 1,000-holder floor is unconditional (see Data Model). An unknown
|
||||||
threshold applies to the transaction history alone.
|
holder count passes the history and send-selector filters but not that floor.
|
||||||
|
The dust threshold applies to the transaction history alone.
|
||||||
|
|
||||||
#### Phishing Domain Protection
|
#### Phishing Domain Protection
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,159 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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
|
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
|
||||||
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
|
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
|
||||||
every request's full URL and body, so an RPC endpoint with a key in its path
|
every request's full URL and body, so an RPC endpoint with a key in its path
|
||||||
@@ -52,8 +205,11 @@ but the review is broader than any of them.
|
|||||||
method, the URL's origin and, for a JSON-RPC call, the method name. The
|
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
|
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
|
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. The README's
|
HTTP status, not its full message, which carries the request URL. A failed
|
||||||
DEBUG Mode Policy says what debug mode logs.
|
endpoint check in settings names the endpoint by its origin, not the `fetch`
|
||||||
|
error's message, which carries the whole URL, password included, for a URL
|
||||||
|
with a user name and password. The README's DEBUG Mode Policy says what debug
|
||||||
|
mode logs.
|
||||||
|
|
||||||
- 2026-10-04: A site has at most one connection prompt and one signature prompt
|
- 2026-10-04: A site has at most one connection prompt and one signature prompt
|
||||||
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
|
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
|
||||||
|
|||||||
+10
-2
@@ -240,7 +240,9 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
|
|||||||
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
||||||
that survives the spam filters).
|
that survives the spam filters).
|
||||||
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
||||||
4. Enter the amount.
|
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
|
||||||
|
decimal places, or your ETH balance minus the amount reserved for the network
|
||||||
|
fee.
|
||||||
5. Click "Review" to see the confirmation screen.
|
5. Click "Review" to see the confirmation screen.
|
||||||
|
|
||||||
The confirmation screen shows:
|
The confirmation screen shows:
|
||||||
@@ -333,7 +335,13 @@ it is hidden from your transaction history and from the send token list.
|
|||||||
from transaction history and the send token list, and are left out of your
|
from transaction history and the send token list, and are left out of your
|
||||||
balances unless they are on the bundled known-token list or you added them
|
balances unless they are on the bundled known-token list or you added them
|
||||||
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
|
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
|
||||||
for address poisoning typically have zero.
|
for address poisoning typically have zero. When the explorer reports no holder
|
||||||
|
count for a token, or reports something other than a whole number in plain
|
||||||
|
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
|
||||||
|
token from your transaction history or the send token list, and it does not get
|
||||||
|
a token into your balances either: such a token is listed only if it is on the
|
||||||
|
bundled known-token list or you added it yourself. The screen you reach by
|
||||||
|
clicking a token balance shows a "Holders:" line only when the count is known.
|
||||||
|
|
||||||
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
||||||
adds the contract address to a local blocklist. Future transactions from that
|
adds the contract address to a local blocklist. Future transactions from that
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open approval in a separate popup window.
|
// Open approval in a separate popup window, unless it is no longer pending.
|
||||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||||
// not from a user gesture) and the fallback for site-connection approvals.
|
// not from a user gesture) and the fallback for site-connection approvals.
|
||||||
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
||||||
@@ -446,6 +446,10 @@ 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);
|
||||||
|
|||||||
+30
-20
@@ -551,12 +551,20 @@
|
|||||||
class="text-xs text-muted"
|
class="text-xs text-muted"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="flex gap-1">
|
||||||
type="text"
|
<input
|
||||||
id="send-amount"
|
type="text"
|
||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
id="send-amount"
|
||||||
placeholder="0.0"
|
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
|
||||||
/>
|
placeholder="0.0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="btn-send-max"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
Max
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="btn-send-review"
|
id="btn-send-review"
|
||||||
@@ -640,19 +648,13 @@
|
|||||||
Double-check the address before sending.
|
Double-check the address before sending.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Its sentence names the network's native token, so show()
|
||||||
|
in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-contract-warning"
|
id="confirm-contract-warning"
|
||||||
class="mb-2"
|
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
>
|
></div>
|
||||||
<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
|
<div
|
||||||
id="confirm-burn-warning"
|
id="confirm-burn-warning"
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
@@ -690,14 +692,13 @@
|
|||||||
Your balance does not cover this amount plus the network
|
Your balance does not cover this amount plus the network
|
||||||
fee. Please go back and send a smaller amount.
|
fee. Please go back and send a smaller amount.
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Its sentence names the network's native token, so show()
|
||||||
|
in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-gas-error"
|
id="confirm-gas-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
>
|
></div>
|
||||||
You do not have enough ETH to pay the network fee for this
|
|
||||||
transfer. Please add ETH to this address and try again.
|
|
||||||
</div>
|
|
||||||
<!-- Its sentence names why the fee could not be estimated,
|
<!-- Its sentence names why the fee could not be estimated,
|
||||||
so show() in confirmTx.js sets it. -->
|
so show() in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
@@ -1698,6 +1699,15 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="approve-sign-hex-section" class="mb-3 hidden">
|
||||||
|
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||||
|
<div
|
||||||
|
id="approve-sign-hex"
|
||||||
|
class="text-xs break-all"
|
||||||
|
style="max-height: 6rem; overflow-y: auto"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+27
-2
@@ -50,6 +50,10 @@ 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;
|
||||||
@@ -155,7 +159,28 @@ async function init() {
|
|||||||
// reported rather than being swallowed by the save queue
|
// reported rather than being swallowed by the save queue
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||||
// the approval-window branch below too, since that window saves as well.
|
// the approval-window branch below too, since that window saves as well.
|
||||||
onSaveFailure(showSaveFailureBanner);
|
//
|
||||||
|
// Every save first reads the stored record and refuses it with the same
|
||||||
|
// check loadState() runs below. So a record that becomes unreadable while
|
||||||
|
// the popup is open is found by the next save, a navigation or the
|
||||||
|
// ten-second refresh, and gets the screen it would get at open
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
|
||||||
|
// screen to showView() first leaves the current screen as any navigation
|
||||||
|
// does, so a phrase, key or password on it is wiped, and from then on
|
||||||
|
// showView() shows nothing else. A later save that fails the same way,
|
||||||
|
// such as a refresh already in flight, comes back here, where both calls
|
||||||
|
// see the screen already up and do nothing. Any other failed save is a
|
||||||
|
// read or write that failed, and gets the banner without changing the
|
||||||
|
// screen.
|
||||||
|
onSaveFailure((e) => {
|
||||||
|
if (e instanceof StateUnusableError) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
showView("state-recovery");
|
||||||
|
stateRecovery.show(e);
|
||||||
|
} else {
|
||||||
|
showSaveFailureBanner(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await loadState();
|
await loadState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -244,7 +269,7 @@ async function init() {
|
|||||||
renderWalletList();
|
renderWalletList();
|
||||||
restoreView();
|
restoreView();
|
||||||
doRefreshAndRender();
|
doRefreshAndRender();
|
||||||
setInterval(doRefreshAndRender, 10000);
|
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,3 +64,13 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A personal message on the signature screen is laid out left to right in
|
||||||
|
* the order of its bytes. Without this, right-to-left characters in it move
|
||||||
|
* the characters around them: `5`, U+05C3, `00` would read as `500`
|
||||||
|
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
|
||||||
|
* the text after it, so src/popup/views/approval.js shows one as a mark. */
|
||||||
|
.am-byte-order {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
@@ -153,6 +153,7 @@ async function loadTransactions(address) {
|
|||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
|
currentNetwork().chainId,
|
||||||
);
|
);
|
||||||
const result = filterTransactions(rawTxs, {
|
const result = filterTransactions(rawTxs, {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
nativeCurrency,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
@@ -17,7 +18,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
@@ -106,7 +107,7 @@ function show() {
|
|||||||
let symbol, amount, price;
|
let symbol, amount, price;
|
||||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
||||||
if (tokenId === "ETH") {
|
if (tokenId === "ETH") {
|
||||||
symbol = "ETH";
|
symbol = nativeCurrency();
|
||||||
amount = parseFloat(addr.balance || "0");
|
amount = parseFloat(addr.balance || "0");
|
||||||
price = getPrice("ETH");
|
price = getPrice("ETH");
|
||||||
} else {
|
} else {
|
||||||
@@ -226,6 +227,7 @@ async function loadTransactions(address, tokenId) {
|
|||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
|
currentNetwork().chainId,
|
||||||
);
|
);
|
||||||
const result = filterTransactions(rawTxs, {
|
const result = filterTransactions(rawTxs, {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
|
|||||||
+76
-15
@@ -12,7 +12,10 @@ const {
|
|||||||
formatFee,
|
formatFee,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { networkByChainId } = require("../../shared/networks");
|
const {
|
||||||
|
networkByChainId,
|
||||||
|
nativeCurrencyByChainId,
|
||||||
|
} = require("../../shared/networks");
|
||||||
const {
|
const {
|
||||||
formatEther,
|
formatEther,
|
||||||
formatUnits,
|
formatUnits,
|
||||||
@@ -26,6 +29,7 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
|
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
resolveTokenSymbol,
|
||||||
@@ -218,8 +222,12 @@ function showTxFee(approvedTx) {
|
|||||||
const gasLimit = BigInt(approvedTx.gasLimit);
|
const gasLimit = BigInt(approvedTx.gasLimit);
|
||||||
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
||||||
// Through formatFee(), as the confirmation screen's fee is, so the same
|
// Through formatFee(), as the confirmation screen's fee is, so the same
|
||||||
// fee reads the same on both.
|
// fee reads the same on both. In the native currency of the network shown
|
||||||
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
|
// above, as the value is.
|
||||||
|
$("approve-tx-fee").textContent = formatFee(
|
||||||
|
gasLimit * feePerGas,
|
||||||
|
nativeCurrencyByChainId(approvedTx.chainId),
|
||||||
|
);
|
||||||
|
|
||||||
let detail =
|
let detail =
|
||||||
gasLimit.toString() +
|
gasLimit.toString() +
|
||||||
@@ -263,6 +271,7 @@ function showTxApproval(details) {
|
|||||||
amount: formatTxValue(ethValue),
|
amount: formatTxValue(ethValue),
|
||||||
token: "ETH",
|
token: "ETH",
|
||||||
tokenSymbol: null,
|
tokenSymbol: null,
|
||||||
|
chainId: approvedTx.chainId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this is an ERC-20 call, try to extract the real recipient and amount
|
// If this is an ERC-20 call, try to extract the real recipient and amount
|
||||||
@@ -328,8 +337,15 @@ function showTxApproval(details) {
|
|||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||||
const usdStr = formatUsd(ethUsd);
|
const usdStr = formatUsd(ethUsd);
|
||||||
|
// In the native currency of the network the transaction is for, which the
|
||||||
|
// Network line names, not the active network's: a site can switch the
|
||||||
|
// active network after this transaction is prepared and back before it is
|
||||||
|
// signed.
|
||||||
$("approve-tx-value").textContent =
|
$("approve-tx-value").textContent =
|
||||||
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
ethValueFormatted +
|
||||||
|
" " +
|
||||||
|
nativeCurrencyByChainId(approvedTx.chainId) +
|
||||||
|
(usdStr ? " (" + usdStr + ")" : "");
|
||||||
|
|
||||||
showTxFee(approvedTx);
|
showTxFee(approvedTx);
|
||||||
|
|
||||||
@@ -380,20 +396,48 @@ function showTxApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether a personal message is hex by the rule signing reads it with:
|
||||||
|
// signing takes getBytes(message), which throws on anything else.
|
||||||
|
function isHexMessage(message) {
|
||||||
|
try {
|
||||||
|
getBytes(message);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text the hex message's bytes decode to as UTF-8, or null when they are
|
||||||
|
// not UTF-8. The caller has checked that the message is hex.
|
||||||
function decodeHexMessage(hex) {
|
function decodeHexMessage(hex) {
|
||||||
try {
|
try {
|
||||||
const bytes = Uint8Array.from(
|
return toUtf8String(getBytes(hex));
|
||||||
hex
|
|
||||||
.slice(2)
|
|
||||||
.match(/.{1,2}/g)
|
|
||||||
.map((b) => parseInt(b, 16)),
|
|
||||||
);
|
|
||||||
return toUtf8String(bytes);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A character shown as a bordered U+XXXX mark.
|
||||||
|
function codePointMark(c) {
|
||||||
|
const code = c.codePointAt(0).toString(16).toUpperCase();
|
||||||
|
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text as HTML, with each character that paints nothing (zero-width and
|
||||||
|
// bidirectional characters, variation selectors and Hangul fillers among
|
||||||
|
// them), each control character and each line or paragraph separator
|
||||||
|
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
|
||||||
|
// Left in the text, a paragraph separator would end the byte-order layout
|
||||||
|
// for everything after it. The marks are plain ASCII, so the second pass
|
||||||
|
// leaves them be.
|
||||||
|
function markInvisibleCharacters(text) {
|
||||||
|
return escapeHtml(text)
|
||||||
|
.replace(INVISIBLE_CHARACTERS, codePointMark)
|
||||||
|
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
|
||||||
|
c === "\n" ? "<br>" : codePointMark(c),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The type ethers will sign typed data as. ethers does not read the page's
|
// The type ethers will sign typed data as. ethers does not read the page's
|
||||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||||
// refers to. Throws when the types name no such single struct, which ethers
|
// refers to. Throws when the types name no such single struct, which ethers
|
||||||
@@ -657,15 +701,33 @@ function showSignApproval(details) {
|
|||||||
? "Typed data (EIP-712)"
|
? "Typed data (EIP-712)"
|
||||||
: "Personal message";
|
: "Personal message";
|
||||||
|
|
||||||
|
// A personal message is signed as the bytes its hex encodes, so the hex
|
||||||
|
// is shown as well as any text it decodes to, and that text is laid out
|
||||||
|
// left to right in the order of its bytes. Signing reads the bytes from
|
||||||
|
// the hex, so a message that is not hex cannot be signed: it is shown as
|
||||||
|
// the text it is, and refused.
|
||||||
|
let refusal = null;
|
||||||
|
$("approve-sign-hex-section").classList.add("hidden");
|
||||||
|
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
|
||||||
if (isTyped) {
|
if (isTyped) {
|
||||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||||
} else {
|
refusal = typedDataRefusal(sp);
|
||||||
|
} else if (isHexMessage(sp.message)) {
|
||||||
const decoded = decodeHexMessage(sp.message);
|
const decoded = decodeHexMessage(sp.message);
|
||||||
if (decoded !== null) {
|
if (decoded !== null) {
|
||||||
$("approve-sign-message").textContent = decoded;
|
$("approve-sign-message").innerHTML =
|
||||||
|
markInvisibleCharacters(decoded);
|
||||||
} else {
|
} else {
|
||||||
$("approve-sign-message").textContent = sp.message;
|
$("approve-sign-message").textContent = "This message is not text.";
|
||||||
}
|
}
|
||||||
|
$("approve-sign-hex").textContent = sp.message;
|
||||||
|
$("approve-sign-hex-section").classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
$("approve-sign-message").innerHTML = markInvisibleCharacters(
|
||||||
|
sp.message,
|
||||||
|
);
|
||||||
|
refusal =
|
||||||
|
"This message is plain text, not hex, so it cannot be signed.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display danger warning for eth_sign (raw hash signing)
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
@@ -687,7 +749,6 @@ function showSignApproval(details) {
|
|||||||
|
|
||||||
showView("approve-sign");
|
showView("approve-sign");
|
||||||
attachCopyHandlers("view-approve-sign");
|
attachCopyHandlers("view-approve-sign");
|
||||||
const refusal = typedDataRefusal(sp);
|
|
||||||
if (refusal) {
|
if (refusal) {
|
||||||
showError("approve-sign-error", refusal);
|
showError("approve-sign-error", refusal);
|
||||||
$("btn-approve-sign").disabled = true;
|
$("btn-approve-sign").disabled = true;
|
||||||
|
|||||||
+100
-29
@@ -11,13 +11,14 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
nativeCurrency,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
formatFee,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
@@ -42,6 +43,7 @@ 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");
|
||||||
@@ -53,6 +55,10 @@ let pendingTx = null;
|
|||||||
// filled in by estimateGas() when the estimate resolves or fails.
|
// filled in by estimateGas() when the estimate resolves or fails.
|
||||||
let feeStatus = FEE_PENDING;
|
let feeStatus = FEE_PENDING;
|
||||||
let feeWei = null;
|
let feeWei = null;
|
||||||
|
// The fee fields a max ETH send is signed with: those of the estimate its
|
||||||
|
// amount was derived from. Null for any other send, which ethers prices from
|
||||||
|
// the node at signing time.
|
||||||
|
let maxSendFees = null;
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -81,16 +87,30 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
||||||
|
// once its amount is re-derived from the fee estimate.
|
||||||
|
function renderAmount(txInfo) {
|
||||||
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
|
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||||
|
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
|
||||||
|
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||||
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
|
txInfo.amount + " " + displaySymbol(rawSymbol),
|
||||||
|
amountUsd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = txInfo;
|
pendingTx = txInfo;
|
||||||
feeStatus = FEE_PENDING;
|
feeStatus = FEE_PENDING;
|
||||||
feeWei = null;
|
feeWei = null;
|
||||||
|
maxSendFees = null;
|
||||||
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
// The raw symbol is the price-table key; the capped one is what the
|
// The raw symbol is the price-table key; the capped one is what the
|
||||||
// screen says. Truncating before the lookup would silently drop the
|
// screen says. Truncating before the lookup would silently drop the
|
||||||
// price of any token whose symbol is long enough to be capped.
|
// price of any token whose symbol is long enough to be capped.
|
||||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||||
const symbol = displaySymbol(rawSymbol);
|
const symbol = displaySymbol(rawSymbol);
|
||||||
|
|
||||||
// Transaction type
|
// Transaction type
|
||||||
@@ -98,7 +118,7 @@ function show(txInfo) {
|
|||||||
$("confirm-type").textContent =
|
$("confirm-type").textContent =
|
||||||
"ERC-20 token transfer (" + symbol + ")";
|
"ERC-20 token transfer (" + symbol + ")";
|
||||||
} else {
|
} else {
|
||||||
$("confirm-type").textContent = "Native ETH transfer";
|
$("confirm-type").textContent = "Native " + symbol + " transfer";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token contract section (ERC-20 only)
|
// Token contract section (ERC-20 only)
|
||||||
@@ -131,18 +151,11 @@ function show(txInfo) {
|
|||||||
);
|
);
|
||||||
$("confirm-to-ens").classList.add("hidden");
|
$("confirm-to-ens").classList.add("hidden");
|
||||||
|
|
||||||
// Amount (with inline USD)
|
renderAmount(txInfo);
|
||||||
const ethPrice = getPrice("ETH");
|
|
||||||
const tokenPrice = getPrice(rawSymbol);
|
|
||||||
const amountNum = parseFloat(txInfo.amount);
|
|
||||||
const price = isErc20 ? tokenPrice : ethPrice;
|
|
||||||
const amountUsd = price ? amountNum * price : null;
|
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
|
||||||
txInfo.amount + " " + symbol,
|
|
||||||
amountUsd,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Balance (with inline USD)
|
// Balance (with inline USD)
|
||||||
|
const ethPrice = getPrice("ETH");
|
||||||
|
const tokenPrice = getPrice(rawSymbol);
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
// null is a balance whose scale nothing knows, not a balance of zero
|
// null is a balance whose scale nothing knows, not a balance of zero
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||||
@@ -162,7 +175,7 @@ function show(txInfo) {
|
|||||||
const bal = txInfo.balance || "0";
|
const bal = txInfo.balance || "0";
|
||||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
$("confirm-balance").textContent = valueWithUsd(
|
||||||
truncateAmountNeverZero(bal) + " ETH",
|
truncateAmountNeverZero(bal) + " " + symbol,
|
||||||
balUsd,
|
balUsd,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,6 +210,19 @@ function show(txInfo) {
|
|||||||
// estimate landing later never moves anything.
|
// estimate landing later never moves anything.
|
||||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||||
|
$("confirm-gas-error").textContent =
|
||||||
|
"You do not have enough " +
|
||||||
|
nativeCurrency() +
|
||||||
|
" to pay the network fee for this transfer. Please add " +
|
||||||
|
nativeCurrency() +
|
||||||
|
" to this address and try again.";
|
||||||
|
|
||||||
|
// Shown later, once checkRecipientHistory() finds a contract.
|
||||||
|
$("confirm-contract-warning").textContent =
|
||||||
|
"WARNING: The recipient is a smart contract. Sending " +
|
||||||
|
nativeCurrency() +
|
||||||
|
" or tokens directly to a contract may result in permanent loss of" +
|
||||||
|
" funds.";
|
||||||
|
|
||||||
// The fee-unknown message names its cause, which is also known here.
|
// The fee-unknown message names its cause, which is also known here.
|
||||||
// Without the token's scale estimateGas() cannot encode the transfer, so
|
// Without the token's scale estimateGas() cannot encode the transfer, so
|
||||||
@@ -245,7 +271,9 @@ function show(txInfo) {
|
|||||||
// touches already occupies its space, so re-running it never moves anything.
|
// touches already occupies its space, so re-running it never moves anything.
|
||||||
function renderValidation(txInfo) {
|
function renderValidation(txInfo) {
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
|
const symbol = isErc20
|
||||||
|
? displaySymbol(txInfo.tokenSymbol || "?")
|
||||||
|
: nativeCurrency();
|
||||||
|
|
||||||
const { canSend, codes } = validateTransfer({
|
const { canSend, codes } = validateTransfer({
|
||||||
isErc20,
|
isErc20,
|
||||||
@@ -258,7 +286,7 @@ function renderValidation(txInfo) {
|
|||||||
|
|
||||||
// Messages carrying the user's own numbers are built here; the fixed
|
// Messages carrying the user's own numbers are built here; the fixed
|
||||||
// sentences live in the reserved elements in index.html, except the
|
// sentences live in the reserved elements in index.html, except the
|
||||||
// fee-unknown one, which show() sets.
|
// gas and fee-unknown ones, which show() sets.
|
||||||
const messages = [];
|
const messages = [];
|
||||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||||
messages.push("Please enter a valid amount to send.");
|
messages.push("Please enter a valid amount to send.");
|
||||||
@@ -287,9 +315,13 @@ function renderValidation(txInfo) {
|
|||||||
messages.push(
|
messages.push(
|
||||||
"Insufficient balance. You have " +
|
"Insufficient balance. You have " +
|
||||||
truncateAmountNeverZero(txInfo.balance || "0") +
|
truncateAmountNeverZero(txInfo.balance || "0") +
|
||||||
" ETH but are trying to send " +
|
" " +
|
||||||
|
symbol +
|
||||||
|
" but are trying to send " +
|
||||||
txInfo.amount +
|
txInfo.amount +
|
||||||
" ETH.",
|
" " +
|
||||||
|
symbol +
|
||||||
|
".",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +392,8 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// What the node will require to be reserved, which is what the gate
|
// What the node will require to be reserved, which is what the gate
|
||||||
// must be: the send pins no fee fields, so it is broadcast as a
|
// must be: the send is broadcast as a type-2 transaction priced at
|
||||||
// type-2 transaction priced at maxFeePerGas.
|
// maxFeePerGas, which only a max ETH send pins (see below).
|
||||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||||
if (gasCostWei === null) {
|
if (gasCostWei === null) {
|
||||||
throw new Error("no usable gas price from the provider");
|
throw new Error("no usable gas price from the provider");
|
||||||
@@ -378,21 +410,51 @@ async function estimateGas(txInfo) {
|
|||||||
// The fee line goes through formatFee(), as the approval screen's
|
// The fee line goes through formatFee(), as the approval screen's
|
||||||
// does, so the same fee reads the same on both.
|
// does, so the same fee reads the same on both.
|
||||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||||
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
|
$("confirm-fee-amount").textContent =
|
||||||
|
"~" + formatFee(estimateWei, nativeCurrency());
|
||||||
$("confirm-fee-reserve").textContent =
|
$("confirm-fee-reserve").textContent =
|
||||||
"up to " +
|
"up to " +
|
||||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
||||||
" ETH reserved";
|
" " +
|
||||||
|
nativeCurrency() +
|
||||||
|
" reserved";
|
||||||
setVisible("confirm-fee-reserve", true);
|
setVisible("confirm-fee-reserve", true);
|
||||||
} else {
|
} else {
|
||||||
// No spread to report: either there is no estimate, or the node
|
// No spread to report: either there is no estimate, or the node
|
||||||
// quotes a gas price at or above maxFeePerGas, so the expected
|
// quotes a gas price at or above maxFeePerGas, so the expected
|
||||||
// cost is not below the reserve. Show the reserve alone.
|
// cost is not below the reserve. Show the reserve alone.
|
||||||
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
|
$("confirm-fee-amount").textContent = formatFee(
|
||||||
|
gasCostWei,
|
||||||
|
nativeCurrency(),
|
||||||
|
);
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
}
|
}
|
||||||
feeStatus = FEE_KNOWN;
|
feeStatus = FEE_KNOWN;
|
||||||
feeWei = gasCostWei;
|
feeWei = gasCostWei;
|
||||||
|
// A max ETH send is the balance minus this estimate's reserve, not the
|
||||||
|
// Send screen's, and is signed with this estimate's fee fields: fees
|
||||||
|
// fetched again at signing could exceed the reserve it leaves, and the
|
||||||
|
// node would refuse it for want of funds. Where the balance no longer
|
||||||
|
// covers the fee, the amount is left as it is and the balance check
|
||||||
|
// below says so.
|
||||||
|
if (txInfo.max && txInfo.token === "ETH") {
|
||||||
|
const amount = maxEthAmount(txInfo.balance, gasCostWei);
|
||||||
|
if (amount !== null) {
|
||||||
|
txInfo.amount = amount;
|
||||||
|
renderAmount(txInfo);
|
||||||
|
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
|
||||||
|
// or gasPrice on a network with no type-2 pricing.
|
||||||
|
if (feeData.maxFeePerGas != null) {
|
||||||
|
maxSendFees = {
|
||||||
|
gasLimit,
|
||||||
|
maxFeePerGas: feeData.maxFeePerGas,
|
||||||
|
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
||||||
@@ -406,18 +468,19 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Populate the transaction this send describes, enforce the fee bound against
|
// Populate the transaction this send describes, enforce the fee bound against
|
||||||
// the fees that were actually filled in, then sign and broadcast it. The send
|
// the fees that were actually filled in, then sign and broadcast it. Apart
|
||||||
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
|
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
|
||||||
// the configured RPC node answers, with nothing otherwise bounding what a
|
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
|
||||||
|
// what the configured RPC node answers, with nothing otherwise bounding what a
|
||||||
// hostile node can set — the dApp path's ceilings never reached this one.
|
// hostile node can set — the dApp path's ceilings never reached this one.
|
||||||
// Populating before the check is what makes assertWithinCeilings() see the
|
// Populating before the check is what makes assertWithinCeilings() see the
|
||||||
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
||||||
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
||||||
// shows in the reserved error area rather than sending.
|
// shows in the reserved error area rather than sending.
|
||||||
async function populateVerifyAndSend(connectedSigner, tx) {
|
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||||
let request;
|
let request;
|
||||||
if (tx.token === "ETH") {
|
if (tx.token === "ETH") {
|
||||||
request = { to: tx.to, value: parseEther(tx.amount) };
|
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
|
||||||
} else {
|
} else {
|
||||||
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
||||||
// The contract's decimals() is read to be COMPARED with the scale the
|
// The contract's decimals() is read to be COMPARED with the scale the
|
||||||
@@ -508,6 +571,10 @@ function init(_ctx) {
|
|||||||
$("btn-confirm-send").disabled = true;
|
$("btn-confirm-send").disabled = true;
|
||||||
$("btn-confirm-send").classList.add("text-muted");
|
$("btn-confirm-send").classList.add("text-muted");
|
||||||
|
|
||||||
|
// The network it is sent on. The wait, success and error screens
|
||||||
|
// label its amount by this, not by the network active when they draw.
|
||||||
|
pendingTx.chainId = currentNetwork().chainId;
|
||||||
|
|
||||||
let tx;
|
let tx;
|
||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(
|
const signer = getSignerForAddress(
|
||||||
@@ -518,7 +585,11 @@ function init(_ctx) {
|
|||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const connectedSigner = signer.connect(provider);
|
const connectedSigner = signer.connect(provider);
|
||||||
|
|
||||||
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
|
tx = await populateVerifyAndSend(
|
||||||
|
connectedSigner,
|
||||||
|
pendingTx,
|
||||||
|
maxSendFees,
|
||||||
|
);
|
||||||
|
|
||||||
// Best-effort: clear decrypted secret after use.
|
// Best-effort: clear decrypted secret after use.
|
||||||
// Note: JS strings are immutable; this nulls the reference but
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
removeWalletFromState,
|
removeWalletFromState,
|
||||||
broadcastActiveChanged,
|
broadcastActiveChanged,
|
||||||
} = require("../../shared/walletDelete");
|
} = require("../../shared/walletDelete");
|
||||||
|
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||||
|
|
||||||
let deleteWalletIndex = null;
|
let deleteWalletIndex = null;
|
||||||
let lostPasswordIndex = null;
|
let lostPasswordIndex = null;
|
||||||
@@ -20,21 +21,31 @@ let ctx = null;
|
|||||||
// The name shown for a wallet, and on the lost-password screen the string
|
// The name shown for a wallet, and on the lost-password screen the string
|
||||||
// the user has to type back. One function so the two cannot disagree: a
|
// the user has to type back. One function so the two cannot disagree: a
|
||||||
// confirmation that asks for a name other than the one on screen is
|
// confirmation that asks for a name other than the one on screen is
|
||||||
// unusable.
|
// unusable. A name that shows nothing at all (only spaces, or only
|
||||||
|
// zero-width characters) is replaced by "Wallet N" for the same reason:
|
||||||
|
// there would be nothing on screen to type back.
|
||||||
function displayName(walletIdx) {
|
function displayName(walletIdx) {
|
||||||
const wallet = state.wallets[walletIdx];
|
const wallet = state.wallets[walletIdx];
|
||||||
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
|
const name = wallet && wallet.name;
|
||||||
|
if (name && confirmKey(name)) return name;
|
||||||
|
return "Wallet " + (walletIdx + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the typed confirmation and the wallet name are compared as. HTML
|
// What the typed confirmation and the wallet name are compared as. HTML
|
||||||
// collapses runs of whitespace when it renders the name, so a wallet named
|
// collapses runs of whitespace when it renders the name, so a wallet named
|
||||||
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
|
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
|
||||||
// see the second space and cannot type a string that matches the stored
|
// see the second space and cannot type a string that matches the stored
|
||||||
// name. Comparing collapsed on both sides is what keeps the confirmation
|
// name. Characters that paint nothing, such as a zero-width space, are
|
||||||
// satisfiable, on the one screen whose whole purpose is unwedging a user
|
// invisible the same way and are removed first. Comparing this form on
|
||||||
// who is already stuck. Case and surrounding space go the same way.
|
// both sides is what keeps the confirmation satisfiable, on the one screen
|
||||||
|
// whose whole purpose is unwedging a user who is already stuck. Case and
|
||||||
|
// surrounding space go the same way.
|
||||||
function confirmKey(name) {
|
function confirmKey(name) {
|
||||||
return name.trim().replace(/\s+/g, " ").toLowerCase();
|
return name
|
||||||
|
.replace(INVISIBLE_CHARACTERS, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the password from the DOM and the wallet selection from the
|
// Drop the password from the DOM and the wallet selection from the
|
||||||
@@ -174,14 +185,16 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case, surrounding spaces and repeated inner spaces are not part
|
// Case, surrounding spaces, repeated inner spaces and invisible
|
||||||
// of the confirmation; see confirmKey(). This asks whether the
|
// characters are not part of the confirmation; see confirmKey().
|
||||||
// user knows which wallet they are on; it is not a secret, and
|
// This asks whether the user knows which wallet they are on; it is
|
||||||
// refusing "wallet 2" for "Wallet 2" would only teach the user to
|
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
|
||||||
// distrust the control.
|
// teach the user to distrust the control. An empty field is
|
||||||
const typed = $("delete-wallet-lost-name-input").value;
|
// refused whatever the wallet is called, so no stored name can
|
||||||
|
// ever be confirmed by typing nothing.
|
||||||
|
const typed = confirmKey($("delete-wallet-lost-name-input").value);
|
||||||
const expected = displayName(lostPasswordIndex);
|
const expected = displayName(lostPasswordIndex);
|
||||||
if (confirmKey(typed) !== confirmKey(expected)) {
|
if (typed === "" || typed !== confirmKey(expected)) {
|
||||||
$("delete-wallet-lost-flash").textContent =
|
$("delete-wallet-lost-flash").textContent =
|
||||||
"That is not the name of this wallet. Type " +
|
"That is not the name of this wallet. Type " +
|
||||||
expected +
|
expected +
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ const VIEWS = [
|
|||||||
"export-privkey",
|
"export-privkey",
|
||||||
"show-phrase",
|
"show-phrase",
|
||||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||||
// cannot be read. It is never reached through showView() — by then the
|
// cannot be read, never by showView() (see there), but listed so that
|
||||||
// state singleton this file writes on every navigation refuses to be read
|
// every view-hiding loop covers it.
|
||||||
// — but it is listed so that every view-hiding loop covers it.
|
|
||||||
"state-recovery",
|
"state-recovery",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -85,12 +84,28 @@ function hideError(id) {
|
|||||||
el.style.visibility = "hidden";
|
el.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set when src/popup/index.js passes the recovery screen to showView(), and
|
||||||
|
// never cleared. Kept in memory for this popup's life, never in
|
||||||
|
// state.currentView, which is saved: a popup opened later must not inherit it.
|
||||||
|
let stateRecoveryShown = false;
|
||||||
|
|
||||||
function showView(name) {
|
function showView(name) {
|
||||||
|
// The recovery screen, once up, is never replaced: work still running
|
||||||
|
// when it went up, such as a transaction wait, must not take the user off
|
||||||
|
// it or clear what they exported or typed there
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
||||||
|
if (stateRecoveryShown) return;
|
||||||
const leaving = state.currentView;
|
const leaving = state.currentView;
|
||||||
if (leaving && leaving !== name) {
|
if (leaving && leaving !== name) {
|
||||||
const onLeave = viewLeaveHandlers.get(leaving);
|
const onLeave = viewLeaveHandlers.get(leaving);
|
||||||
if (onLeave) onLeave();
|
if (onLeave) onLeave();
|
||||||
}
|
}
|
||||||
|
// Passed here only so the screen it replaces is left like any other;
|
||||||
|
// stateRecovery.show() raises it, and it is never the current view.
|
||||||
|
if (name === "state-recovery") {
|
||||||
|
stateRecoveryShown = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const v of VIEWS) {
|
for (const v of VIEWS) {
|
||||||
const el = document.getElementById(`view-${v}`);
|
const el = document.getElementById(`view-${v}`);
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -252,16 +267,31 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
// A network fee in wei as the confirmation and approval screens both show it:
|
// A network fee in wei as the confirmation and approval screens both show it:
|
||||||
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
|
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
|
||||||
// ETH price is known. The USD value is of the exact fee, not of the truncated
|
// native currency of the network the fee is paid on, then its USD value when
|
||||||
// figure.
|
// the ETH price is known. The USD value is of the exact fee, not of the
|
||||||
function formatFee(wei) {
|
// truncated figure.
|
||||||
|
function formatFee(wei, symbol) {
|
||||||
const eth = formatEther(wei);
|
const eth = formatEther(wei);
|
||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
||||||
return (
|
return (
|
||||||
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
|
truncateAmountNeverZero(eth) +
|
||||||
|
" " +
|
||||||
|
symbol +
|
||||||
|
(usd ? " (" + usd + ")" : "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +333,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
|||||||
|
|
||||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||||
let html = balanceLine(
|
let html = balanceLine(
|
||||||
"ETH",
|
nativeCurrency(),
|
||||||
parseFloat(addr.balance || "0"),
|
parseFloat(addr.balance || "0"),
|
||||||
getPrice("ETH"),
|
getPrice("ETH"),
|
||||||
"ETH",
|
"ETH",
|
||||||
@@ -660,6 +690,7 @@ module.exports = {
|
|||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
|
nativeCurrency,
|
||||||
formatFee,
|
formatFee,
|
||||||
addressColor,
|
addressColor,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
|
|||||||
+13
-3
@@ -10,11 +10,17 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
nativeCurrency,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentAddress } = require("../../shared/state");
|
const {
|
||||||
|
state,
|
||||||
|
saveState,
|
||||||
|
currentAddress,
|
||||||
|
currentNetwork,
|
||||||
|
} = require("../../shared/state");
|
||||||
const { notify } = require("../../shared/browserApi");
|
const { notify } = require("../../shared/browserApi");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
@@ -68,7 +74,7 @@ function renderTotalValue() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ethBal = parseFloat(addr.balance || "0");
|
const ethBal = parseFloat(addr.balance || "0");
|
||||||
const ethStr = ethBal.toFixed(4) + " ETH";
|
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
||||||
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
||||||
el.textContent = ethStr + ethUsd;
|
el.textContent = ethStr + ethUsd;
|
||||||
|
|
||||||
@@ -182,7 +188,11 @@ async function loadHomeTxs(ctx) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fetches = allAddresses.map((addr) =>
|
const fetches = allAddresses.map((addr) =>
|
||||||
fetchRecentTransactions(addr, state.blockscoutUrl),
|
fetchRecentTransactions(
|
||||||
|
addr,
|
||||||
|
state.blockscoutUrl,
|
||||||
|
currentNetwork().chainId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const results = await Promise.all(fetches);
|
const results = await Promise.all(fetches);
|
||||||
|
|
||||||
|
|||||||
+119
-4
@@ -5,6 +5,8 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
escapeHtml,
|
||||||
|
nativeCurrency,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -20,10 +22,25 @@ const {
|
|||||||
truncateAmountNeverZero,
|
truncateAmountNeverZero,
|
||||||
isBelowOneMillionth,
|
isBelowOneMillionth,
|
||||||
} = require("../../shared/amountDisplay");
|
} = require("../../shared/amountDisplay");
|
||||||
const { getAddress } = require("ethers");
|
const {
|
||||||
|
feeReserveWei,
|
||||||
|
maxEthAmount,
|
||||||
|
maxTokenAmount,
|
||||||
|
} = require("../../shared/txValidation");
|
||||||
|
const { log } = require("../../shared/log");
|
||||||
|
const { getAddress, parseEther } = require("ethers");
|
||||||
|
|
||||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
// Whether the amount field holds what Max filled in. The confirmation screen
|
||||||
|
// re-derives a max ETH amount from its own fee estimate; typing in the field
|
||||||
|
// makes it an ordinary amount again.
|
||||||
|
let amountIsMax = false;
|
||||||
|
|
||||||
|
// Counts the times the Send screen has opened, so a Max fee estimate started
|
||||||
|
// before it was last opened fills nothing in.
|
||||||
|
let sendScreenOpenings = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a destination address string.
|
* Validate a destination address string.
|
||||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||||
@@ -124,7 +141,7 @@ function updateToValidation() {
|
|||||||
|
|
||||||
function renderSendTokenSelect(addr) {
|
function renderSendTokenSelect(addr) {
|
||||||
const sel = $("send-token");
|
const sel = $("send-token");
|
||||||
sel.innerHTML = '<option value="ETH">ETH</option>';
|
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
|
||||||
const fraudSet = new Set(
|
const fraudSet = new Set(
|
||||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||||
);
|
);
|
||||||
@@ -204,7 +221,8 @@ function updateSendBalance() {
|
|||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
"Current balance: " +
|
"Current balance: " +
|
||||||
truncateAmountNeverZero(addr.balance || "0") +
|
truncateAmountNeverZero(addr.balance || "0") +
|
||||||
" ETH";
|
" " +
|
||||||
|
nativeCurrency();
|
||||||
} else {
|
} else {
|
||||||
const symbol = resolveSymbol(
|
const symbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
@@ -226,9 +244,102 @@ 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", updateSendBalance);
|
$("send-token").addEventListener("change", () => {
|
||||||
|
// A filled-in maximum is the maximum of the holding it was filled in
|
||||||
|
// for.
|
||||||
|
if (amountIsMax) {
|
||||||
|
$("send-amount").value = "";
|
||||||
|
amountIsMax = false;
|
||||||
|
}
|
||||||
|
updateSendBalance();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-send-max").addEventListener("click", fillMaxAmount);
|
||||||
|
$("send-amount").addEventListener("input", () => {
|
||||||
|
amountIsMax = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Initial state: disable review button until address is entered
|
// Initial state: disable review button until address is entered
|
||||||
$("btn-send-review").disabled = true;
|
$("btn-send-review").disabled = true;
|
||||||
@@ -305,6 +416,7 @@ function init(_ctx) {
|
|||||||
tokenSymbol: tokenSymbol,
|
tokenSymbol: tokenSymbol,
|
||||||
tokenBalance: tokenBalance,
|
tokenBalance: tokenBalance,
|
||||||
tokenDecimals: tokenDecimals,
|
tokenDecimals: tokenDecimals,
|
||||||
|
max: amountIsMax,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,7 +427,10 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called each time the Send screen opens, with its fields cleared.
|
||||||
function resetSendValidation() {
|
function resetSendValidation() {
|
||||||
|
sendScreenOpenings++;
|
||||||
|
amountIsMax = false;
|
||||||
const errorEl = $("send-to-error");
|
const errorEl = $("send-to-error");
|
||||||
const btn = $("btn-send-review");
|
const btn = $("btn-send-review");
|
||||||
if (errorEl) errorEl.textContent = "";
|
if (errorEl) errorEl.textContent = "";
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
const {
|
||||||
|
log,
|
||||||
|
debugFetch,
|
||||||
|
urlOrigin,
|
||||||
|
setRuntimeDebug,
|
||||||
|
} = require("../../shared/log");
|
||||||
const deleteWallet = require("./deleteWallet");
|
const deleteWallet = require("./deleteWallet");
|
||||||
const showPhrase = require("./showPhrase");
|
const showPhrase = require("./showPhrase");
|
||||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||||
@@ -272,8 +277,11 @@ function init(ctx) {
|
|||||||
showFlash("Wrong network: expected " + net.name + ".");
|
showFlash("Wrong network: expected " + net.name + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
log.errorf("RPC validation fetch failed:", e.message);
|
// Not the error's message: fetch puts the whole URL, password and
|
||||||
|
// key included, in the message of the error it throws for a URL
|
||||||
|
// with a user name and password or one it cannot parse.
|
||||||
|
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -295,8 +303,9 @@ function init(ctx) {
|
|||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
log.errorf("Blockscout validation failed:", e.message);
|
// Not the error's message, as for the RPC check above.
|
||||||
|
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
// Everything else in the popup assumes a loaded profile: showView() reads and
|
// Everything else in the popup assumes a loaded profile: showView() reads and
|
||||||
// writes the state singleton, every view renders from it, and the Settings
|
// writes the state singleton, every view renders from it, and the Settings
|
||||||
// gear leads to a screen that does both. None of that is available here — by
|
// gear leads to a screen that does both. None of that is available here — by
|
||||||
// the time this runs, loadState() has REFUSED, deliberately, and reading the
|
// the time this runs, the stored record has been REFUSED, deliberately: at
|
||||||
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
// open loadState() refused it and reading the singleton throws
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
|
||||||
|
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
||||||
//
|
//
|
||||||
// So this module talks to the DOM directly and touches no state at all. It is
|
// So this module talks to the DOM directly and touches no state at all. It is
|
||||||
// the one screen that must work when nothing else can, which is also why it
|
// the one screen that must work when nothing else can, which is also why it
|
||||||
@@ -170,6 +172,11 @@ function wire() {
|
|||||||
* refused, or its sentence.
|
* refused, or its sentence.
|
||||||
*/
|
*/
|
||||||
function show(problem) {
|
function show(problem) {
|
||||||
|
// Already up: a later save that trips over the same record, such as a
|
||||||
|
// refresh that was in flight when the screen went up, must not clear what
|
||||||
|
// the user has exported or typed here.
|
||||||
|
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||||
|
|
||||||
const sentence =
|
const sentence =
|
||||||
(problem && (problem.problem || problem.message)) || String(problem);
|
(problem && (problem.problem || problem.message)) || String(problem);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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 makeBlockie = require("ethereum-blockies-base64");
|
||||||
const { log, debugFetch } = require("../../shared/log");
|
const { log, debugFetch } = require("../../shared/log");
|
||||||
@@ -41,8 +42,10 @@ function getTransactionType(tx) {
|
|||||||
return "Token Approval";
|
return "Token Approval";
|
||||||
return "Contract Call";
|
return "Contract Call";
|
||||||
}
|
}
|
||||||
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
|
// By the token contract, not the symbol: a token chooses its own symbol
|
||||||
return "Native ETH Transfer";
|
// and can report the native token's, but only a token transfer has one.
|
||||||
|
if (tx.contractAddress) return "ERC-20 Token Transfer";
|
||||||
|
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockieHtml(address) {
|
function blockieHtml(address) {
|
||||||
@@ -84,6 +87,11 @@ function show(tx) {
|
|||||||
isContractCall: tx.isContractCall || false,
|
isContractCall: tx.isContractCall || false,
|
||||||
method: tx.method || null,
|
method: tx.method || null,
|
||||||
contractAddress: tx.contractAddress || null,
|
contractAddress: tx.contractAddress || null,
|
||||||
|
// The network the history entry was read from. The type line and
|
||||||
|
// the fee are in its native currency, not the active network's:
|
||||||
|
// a site can switch the active network before a later popup
|
||||||
|
// shows this screen again.
|
||||||
|
chainId: tx.chainId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
@@ -179,7 +187,7 @@ function render() {
|
|||||||
if (el) el.classList.add("hidden");
|
if (el) el.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFullTxDetails(tx.hash, tx.to);
|
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
|
||||||
|
|
||||||
const isoStr = isoDate(tx.timestamp);
|
const isoStr = isoDate(tx.timestamp);
|
||||||
$("tx-detail-time").innerHTML =
|
$("tx-detail-time").innerHTML =
|
||||||
@@ -197,7 +205,7 @@ function showDetailField(sectionId, contentId, value) {
|
|||||||
section.classList.remove("hidden");
|
section.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateOnChainDetails(txData) {
|
function populateOnChainDetails(txData, chainId) {
|
||||||
// Block number
|
// Block number
|
||||||
if (txData.block_number != null) {
|
if (txData.block_number != null) {
|
||||||
const blockLink = explorerUrl("block", String(txData.block_number));
|
const blockLink = explorerUrl("block", String(txData.block_number));
|
||||||
@@ -227,7 +235,7 @@ function populateOnChainDetails(txData) {
|
|||||||
showDetailField(
|
showDetailField(
|
||||||
"tx-detail-fee-section",
|
"tx-detail-fee-section",
|
||||||
"tx-detail-fee",
|
"tx-detail-fee",
|
||||||
feeEth + " ETH",
|
feeEth + " " + nativeCurrencyByChainId(chainId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +295,7 @@ function populateOnChainDetails(txData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFullTxDetails(txHash, toAddress) {
|
async function loadFullTxDetails(txHash, toAddress, chainId) {
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
const actionEl = $("tx-detail-calldata-action");
|
const actionEl = $("tx-detail-calldata-action");
|
||||||
const detailsEl = $("tx-detail-calldata-details");
|
const detailsEl = $("tx-detail-calldata-details");
|
||||||
@@ -304,7 +312,7 @@ async function loadFullTxDetails(txHash, toAddress) {
|
|||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData);
|
populateOnChainDetails(txData, chainId);
|
||||||
|
|
||||||
const inputData = txData.raw_input || txData.input || null;
|
const inputData = txData.raw_input || txData.input || null;
|
||||||
if (!inputData || inputData === "0x") return;
|
if (!inputData || inputData === "0x") return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
|
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
@@ -86,9 +87,13 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
endWait();
|
endWait();
|
||||||
const id = waitId;
|
const id = waitId;
|
||||||
|
|
||||||
|
// A native amount, here and on the success and error screens, is in the
|
||||||
|
// native currency of txInfo.chainId, the network the transaction was sent
|
||||||
|
// on, not the active network's: a site can switch the active network
|
||||||
|
// while this screen is open or before a later popup resumes it.
|
||||||
const symbol =
|
const symbol =
|
||||||
txInfo.token === "ETH"
|
txInfo.token === "ETH"
|
||||||
? "ETH"
|
? nativeCurrencyByChainId(txInfo.chainId)
|
||||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
||||||
@@ -193,9 +198,10 @@ function showWait(txInfo, txHash) {
|
|||||||
// an object merely missing one of them throws a TypeError out of
|
// an object merely missing one of them throws a TypeError out of
|
||||||
// restoreView() — which init() does not guard, skipping the rest of popup
|
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||||
// init and leaving wait-tx on screen with no back control. A non-numeric
|
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
|
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
|
||||||
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
|
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
|
||||||
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
|
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
|
||||||
|
// ETH.
|
||||||
function restoreWait() {
|
function restoreWait() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
if (!d || !d.pendingWait) return false;
|
if (!d || !d.pendingWait) return false;
|
||||||
@@ -223,7 +229,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
|||||||
|
|
||||||
const symbol =
|
const symbol =
|
||||||
txInfo.token === "ETH"
|
txInfo.token === "ETH"
|
||||||
? "ETH"
|
? nativeCurrencyByChainId(txInfo.chainId)
|
||||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
@@ -320,7 +326,7 @@ function showError(txInfo, txHash, message) {
|
|||||||
|
|
||||||
const symbol =
|
const symbol =
|
||||||
txInfo.token === "ETH"
|
txInfo.token === "ETH"
|
||||||
? "ETH"
|
? nativeCurrencyByChainId(txInfo.chainId)
|
||||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
// disputed is refused rather than guessed at.
|
// disputed is refused rather than guessed at.
|
||||||
|
|
||||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||||
// reporting that call's result. toDecimals() is that check, shared with the
|
// reporting that call's result. toDecimals() is that check, stopping at the 80
|
||||||
// send path rather than copied: the bundled list stores numbers, the
|
// places formatUnits() accepts, and shared with the send path rather than
|
||||||
// explorer's copy arrives as a string, and a token the user added by hand
|
// copied: the bundled list stores numbers, the explorer's copy arrives as a
|
||||||
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
// string, and a token the user added by hand carries whatever lookupTokenInfo()
|
||||||
// enumerated rather than coerced.
|
// got back, so the accepted types are enumerated rather than coerced.
|
||||||
const { toDecimals } = require("./transferAmount");
|
const { toDecimals } = require("./transferAmount");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ const {
|
|||||||
formatEther,
|
formatEther,
|
||||||
getAddress,
|
getAddress,
|
||||||
getBytes,
|
getBytes,
|
||||||
|
toQuantity,
|
||||||
verifyMessage,
|
verifyMessage,
|
||||||
verifyTypedData,
|
verifyTypedData,
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
|
const { nativeCurrencyByChainId } = require("./networks");
|
||||||
|
|
||||||
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
||||||
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
||||||
@@ -407,12 +409,21 @@ function assertWithinCeilings(tx) {
|
|||||||
price = normalizeQuantity(tx.gasPrice, "gas price");
|
price = normalizeQuantity(tx.gasPrice, "gas price");
|
||||||
}
|
}
|
||||||
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
|
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
|
||||||
|
// The fee is paid in the native currency of the network the
|
||||||
|
// transaction is for. Every caller's transaction names it.
|
||||||
|
const nativeCurrency = nativeCurrencyByChainId(
|
||||||
|
present(tx.chainId) ? toQuantity(tx.chainId) : null,
|
||||||
|
);
|
||||||
throw refuse(
|
throw refuse(
|
||||||
"This transaction would allow a network fee of up to " +
|
"This transaction would allow a network fee of up to " +
|
||||||
formatEther(gasLimit * price) +
|
formatEther(gasLimit * price) +
|
||||||
" ETH, which is more than the " +
|
" " +
|
||||||
|
nativeCurrency +
|
||||||
|
", which is more than the " +
|
||||||
formatEther(MAX_TOTAL_FEE) +
|
formatEther(MAX_TOTAL_FEE) +
|
||||||
" ETH this wallet will sign for.",
|
" " +
|
||||||
|
nativeCurrency +
|
||||||
|
" this wallet will sign for.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 count, which is not the
|
// null means the explorer reported no readable count, which is
|
||||||
// same as a count of zero. This gate is not the low-holder
|
// not the same as a count of zero. This gate is not the
|
||||||
// display filter: it has no user-facing off switch and governs
|
// low-holder display filter: it has no user-facing off switch and
|
||||||
// the whole balance list, so it stays strict and admits a token
|
// governs the whole balance list, so it stays strict and admits a
|
||||||
// only on a reported count — an unreported one is no evidence.
|
// token only on a reported count — an unreported one is no
|
||||||
// A legitimate token still reaches the list through the known
|
// evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A
|
||||||
|
// legitimate token still reaches the list through the known
|
||||||
// token list or by the user tracking it, and the null is carried
|
// token list or by the user tracking it, and the null is carried
|
||||||
// through to the views, where the two low-holder filters treat
|
// through to the views, where the two low-holder filters treat
|
||||||
// an unknown count as "do not judge" rather than as zero.
|
// an unknown count as "do not judge" rather than as zero.
|
||||||
const holders = parseHoldersCount(item.token.holders_count);
|
const holders = parseHoldersCount(item.token.holders_count);
|
||||||
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||||
const isTracked = trackedSet.has(tokenAddr);
|
const isTracked = trackedSet.has(tokenAddr);
|
||||||
const hasEnoughHolders =
|
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
|
||||||
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
|
||||||
|
|
||||||
// Skip spam tokens the user never asked to see
|
// Skip spam tokens the user never asked to see
|
||||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||||
|
|||||||
+15
-6
@@ -9,13 +9,22 @@
|
|||||||
|
|
||||||
const LOW_HOLDER_THRESHOLD = 1000;
|
const LOW_HOLDER_THRESHOLD = 1000;
|
||||||
|
|
||||||
// Parse an explorer-supplied holders_count into a number, or null when the
|
// Parse an explorer-supplied holders_count into a number, or null when it is
|
||||||
// explorer did not report one. Anything unparseable is unknown too: a count
|
// not one. Only a whole number of zero or more, or a string made of nothing
|
||||||
// we cannot read is not a count of zero.
|
// but the digits 0-9, is a count. Anything else is null, never read in part:
|
||||||
|
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
|
||||||
|
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
|
||||||
|
// null too: a number cannot hold it exactly, so it would come back rounded,
|
||||||
|
// or as Infinity.
|
||||||
function parseHoldersCount(raw) {
|
function parseHoldersCount(raw) {
|
||||||
if (raw === null || raw === undefined || raw === "") return null;
|
if (typeof raw === "number") {
|
||||||
const n = parseInt(raw, 10);
|
return Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
|
||||||
return Number.isFinite(n) ? n : null;
|
}
|
||||||
|
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
|
||||||
|
const count = Number(raw);
|
||||||
|
return Number.isSafeInteger(count) ? count : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// True only for a token the explorer reported as having fewer holders than
|
// True only for a token the explorer reported as having fewer holders than
|
||||||
|
|||||||
+2
-1
@@ -43,7 +43,8 @@ const log = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The origin (scheme, host and port) of a URL, for logging in place of the
|
// The origin (scheme, host and port) of a URL, for logging in place of the
|
||||||
// URL: RPC providers put API keys in the path or the query string. A URL that
|
// 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.
|
// does not parse gives "", so logging never stops a request.
|
||||||
function urlOrigin(url) {
|
function urlOrigin(url) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ function networkByChainId(chainId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The native currency of the network with this chain id. A transaction's
|
||||||
|
// value and fee are labelled with the one of the chain the transaction is on,
|
||||||
|
// which need not be the active network. `ETH` when the chain id is missing or
|
||||||
|
// no network here has it.
|
||||||
|
function nativeCurrencyByChainId(chainId) {
|
||||||
|
const network = networkByChainId(chainId);
|
||||||
|
return network ? network.nativeCurrency : "ETH";
|
||||||
|
}
|
||||||
|
|
||||||
// Build a block explorer link for the given path type and value.
|
// Build a block explorer link for the given path type and value.
|
||||||
// type: "address" | "tx" | "token" | "block"
|
// type: "address" | "tx" | "token" | "block"
|
||||||
function explorerLink(network, type, value) {
|
function explorerLink(network, type, value) {
|
||||||
@@ -89,5 +98,6 @@ module.exports = {
|
|||||||
isKnownNetworkId,
|
isKnownNetworkId,
|
||||||
networkById,
|
networkById,
|
||||||
networkByChainId,
|
networkByChainId,
|
||||||
|
nativeCurrencyByChainId,
|
||||||
explorerLink,
|
explorerLink,
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-5
@@ -122,9 +122,9 @@ function currentNetwork() {
|
|||||||
return networkById(state.networkId);
|
return networkById(state.networkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persisted fields as they stood at the end of this page's last
|
// The persisted fields as this page held them when its last loadState()
|
||||||
// loadState() or saveState(). saveState() diffs the live state against this
|
// finished, or when its last successful saveState() began. saveState() diffs
|
||||||
// to find only the fields THIS page actually changed.
|
// the live state against this to find only the fields THIS page changed since.
|
||||||
//
|
//
|
||||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||||
@@ -464,7 +464,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// does not own goes on being whatever its last loadState() saw, same as
|
// does not own goes on being whatever its last loadState() saw, same as
|
||||||
// before this fix; only the persisted record is guaranteed current.
|
// before this fix; only the persisted record is guaranteed current.
|
||||||
async function saveStateOnce() {
|
async function saveStateOnce() {
|
||||||
const current = snapshotPersisted();
|
// A copy, so what this save compares and writes is the page's state as it
|
||||||
|
// stood when the save began. A change made while it waits on storage is
|
||||||
|
// left for the next save, which compares against this copy.
|
||||||
|
const current = structuredClone(snapshotPersisted());
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
// The record in storage right now is about to be merged into and written
|
// The record in storage right now is about to be merged into and written
|
||||||
// back, so it is validated exactly like a load validates it. Without this,
|
// back, so it is validated exactly like a load validates it. Without this,
|
||||||
@@ -521,7 +524,11 @@ async function saveStateOnce() {
|
|||||||
// exactly as it stood; see the note above.
|
// exactly as it stood; see the note above.
|
||||||
rawState.hasWallet = rawState.wallets.length > 0;
|
rawState.hasWallet = rawState.wallets.length > 0;
|
||||||
|
|
||||||
baseline = structuredClone(snapshotPersisted());
|
// What this save compared and wrote, not the page's state now: a change
|
||||||
|
// made during the save must still differ from the baseline, or the save
|
||||||
|
// queued after it finds nothing to store
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
|
||||||
|
baseline = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
// showView() calls saveState() on every navigation without awaiting it, so
|
// showView() calls saveState() on every navigation without awaiting it, so
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
||||||
// that may bear it, or to null. Null means the symbol belongs to the native
|
// that may bear it, or to null. Null means the symbol belongs to the native
|
||||||
// asset, which has no contract at all, so no contract may bear it and every
|
// asset, which has no contract at all, so no contract may bear it and every
|
||||||
// one that does is a spoof. "ETH" is the only such entry today; the rule is
|
// one that does is a spoof. "ETH" is one such entry, and every network's
|
||||||
// written so that a second one needs no change here or at any call site.
|
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
|
||||||
//
|
//
|
||||||
// The value is a set because a ticker is not unique: seven symbols in the
|
// The value is a set because a ticker is not unique: seven symbols in the
|
||||||
// bundled list belong to two real contracts each, and answering with one of
|
// bundled list belong to two real contracts each, and answering with one of
|
||||||
@@ -34,6 +34,11 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The characters that paint nothing; normalizeSymbol below says which they
|
||||||
|
// are. The signature screen marks them in a personal message
|
||||||
|
// (src/popup/views/approval.js).
|
||||||
|
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
||||||
|
|
||||||
// Fold a symbol onto what a user actually sees, and no further:
|
// Fold a symbol onto what a user actually sees, and no further:
|
||||||
//
|
//
|
||||||
// NFKC collapses compatibility variants that render as the ASCII
|
// NFKC collapses compatibility variants that render as the ASCII
|
||||||
@@ -82,7 +87,7 @@ function normalizeAddress(addr) {
|
|||||||
function normalizeSymbol(symbol) {
|
function normalizeSymbol(symbol) {
|
||||||
return String(symbol || "")
|
return String(symbol || "")
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
.replace(INVISIBLE_CHARACTERS, "")
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -104,5 +109,6 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
INVISIBLE_CHARACTERS,
|
||||||
isSpoofedSymbol,
|
isSpoofedSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 511 tokens.
|
// 511 tokens.
|
||||||
|
|
||||||
const { debugFetch } = require("./log");
|
const { debugFetch } = require("./log");
|
||||||
|
const { NETWORKS } = require("./networks");
|
||||||
|
|
||||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
||||||
|
|
||||||
@@ -3610,7 +3611,9 @@ for (const t of TOKENS) {
|
|||||||
// Build a map of symbol (uppercased) -> the set of contract addresses
|
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||||
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
||||||
// "ETH" maps to null: the native asset has no contract, so no contract may
|
// "ETH" maps to null: the native asset has no contract, so no contract may
|
||||||
// bear its symbol.
|
// bear its symbol. So does every network's `nativeCurrency` in networks.js
|
||||||
|
// (`SepoliaETH`), on every network, since that is the label the wallet shows
|
||||||
|
// its native asset under on that network.
|
||||||
//
|
//
|
||||||
// The value is a set and not a single address because tickers are not unique
|
// The value is a set and not a single address because tickers are not unique
|
||||||
// and the list above proves it: seven of these 512 tokens share a symbol with
|
// and the list above proves it: seven of these 512 tokens share a symbol with
|
||||||
@@ -3624,6 +3627,9 @@ for (const t of TOKENS) {
|
|||||||
// loosen the rule, because a contract outside the set is still a spoof.
|
// loosen the rule, because a contract outside the set is still a spoof.
|
||||||
const KNOWN_SYMBOLS = new Map();
|
const KNOWN_SYMBOLS = new Map();
|
||||||
KNOWN_SYMBOLS.set("ETH", null);
|
KNOWN_SYMBOLS.set("ETH", null);
|
||||||
|
for (const network of Object.values(NETWORKS)) {
|
||||||
|
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
|
||||||
|
}
|
||||||
for (const t of TOKENS) {
|
for (const t of TOKENS) {
|
||||||
const upper = t.symbol.toUpperCase();
|
const upper = t.symbol.toUpperCase();
|
||||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
if (!KNOWN_SYMBOLS.has(upper)) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { log, debugFetch } = require("./log");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
|
const { nativeCurrencyByChainId } = require("./networks");
|
||||||
// The uint8 test every scale in this wallet goes through. Shared, not copied:
|
// The uint8 test every scale in this wallet goes through. Shared, not copied:
|
||||||
// a scale is either reported or it is unknown, and "unknown" must mean the
|
// a scale is either reported or it is unknown, and "unknown" must mean the
|
||||||
// same thing here as it does on the screens that refuse to format one.
|
// same thing here as it does on the screens that refuse to format one.
|
||||||
@@ -28,7 +29,7 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTx(tx, addrLower) {
|
function parseTx(tx, addrLower, chainId) {
|
||||||
const from = tx.from?.hash || "";
|
const from = tx.from?.hash || "";
|
||||||
const to = tx.to?.hash || "";
|
const to = tx.to?.hash || "";
|
||||||
const rawWei = tx.value || "0";
|
const rawWei = tx.value || "0";
|
||||||
@@ -36,7 +37,7 @@ function parseTx(tx, addrLower) {
|
|||||||
const method = tx.method || null;
|
const method = tx.method || null;
|
||||||
|
|
||||||
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
||||||
let symbol = "ETH";
|
let symbol = nativeCurrencyByChainId(chainId);
|
||||||
let value = formatTxValue(formatEther(rawWei));
|
let value = formatTxValue(formatEther(rawWei));
|
||||||
let exactValue = formatEther(rawWei);
|
let exactValue = formatEther(rawWei);
|
||||||
let rawAmount = rawWei;
|
let rawAmount = rawWei;
|
||||||
@@ -90,10 +91,11 @@ function parseTx(tx, addrLower) {
|
|||||||
holders: null,
|
holders: null,
|
||||||
isContractCall: toIsContract,
|
isContractCall: toIsContract,
|
||||||
method: method,
|
method: method,
|
||||||
|
chainId: chainId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTokenTransfer(tt, addrLower) {
|
function parseTokenTransfer(tt, addrLower, chainId) {
|
||||||
const from = tt.from?.hash || "";
|
const from = tt.from?.hash || "";
|
||||||
const to = tt.to?.hash || "";
|
const to = tt.to?.hash || "";
|
||||||
// The explorer's own answer, or null. Never a default: a transfer of
|
// The explorer's own answer, or null. Never a default: a transfer of
|
||||||
@@ -135,10 +137,12 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
contractAddress: normalizeAddress(
|
contractAddress: normalizeAddress(
|
||||||
tt.token?.address_hash || tt.token?.address || "",
|
tt.token?.address_hash || tt.token?.address || "",
|
||||||
),
|
),
|
||||||
// null when the explorer reported no count: unknown, not zero. The
|
// null when the explorer reported no readable count: unknown, not
|
||||||
// low-holder filter declines to judge a null, so a legitimate token
|
// zero. The low-holder filter declines to judge a null, so a
|
||||||
// is not hidden because a field went missing upstream.
|
// legitimate token is not hidden because a field went missing
|
||||||
|
// upstream.
|
||||||
holders: parseHoldersCount(tt.token?.holders_count),
|
holders: parseHoldersCount(tt.token?.holders_count),
|
||||||
|
chainId: chainId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +225,15 @@ function mergeTransactions(txs, tokenTransfers) {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
|
||||||
|
// carries it, and a native entry is labelled with that network's
|
||||||
|
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
|
||||||
|
async function fetchRecentTransactions(
|
||||||
|
address,
|
||||||
|
blockscoutUrl,
|
||||||
|
chainId,
|
||||||
|
count = 25,
|
||||||
|
) {
|
||||||
log.debugf("fetchRecentTransactions", address);
|
log.debugf("fetchRecentTransactions", address);
|
||||||
const addrLower = normalizeAddress(address);
|
const addrLower = normalizeAddress(address);
|
||||||
|
|
||||||
@@ -254,8 +266,10 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
|||||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||||
|
|
||||||
const txs = mergeTransactions(
|
const txs = mergeTransactions(
|
||||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
|
||||||
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
(ttJson.items || []).map((tt) =>
|
||||||
|
parseTokenTransfer(tt, addrLower, chainId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = txs.slice(0, count);
|
const result = txs.slice(0, count);
|
||||||
|
|||||||
@@ -27,9 +27,11 @@
|
|||||||
|
|
||||||
const { parseUnits } = require("ethers");
|
const { parseUnits } = require("ethers");
|
||||||
|
|
||||||
// Solidity's decimals() returns a uint8, so anything outside that range is not
|
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and
|
||||||
// an answer this wallet can use.
|
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
|
||||||
const MAX_DECIMALS = 255;
|
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
|
||||||
|
// encoded, so it is not an answer this wallet can use, the same as no answer.
|
||||||
|
const MAX_DECIMALS = 80;
|
||||||
|
|
||||||
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
|
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
|
||||||
"The transfer was not sent, because the number of decimal places this" +
|
"The transfer was not sent, because the number of decimal places this" +
|
||||||
@@ -55,7 +57,7 @@ function mismatchMessage(displayed, onChain) {
|
|||||||
// A decimals value from any source as a number, or null if it is not one.
|
// A decimals value from any source as a number, or null if it is not one.
|
||||||
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
||||||
// as a string, so both of those are accepted alongside a plain number; anything
|
// as a string, so both of those are accepted alongside a plain number; anything
|
||||||
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
// fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
|
||||||
//
|
//
|
||||||
// The types are enumerated rather than coerced because Number() is far too
|
// The types are enumerated rather than coerced because Number() is far too
|
||||||
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Balance arithmetic for the transaction confirmation screen.
|
// Balance arithmetic for the Send and transaction confirmation screens.
|
||||||
//
|
//
|
||||||
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
||||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// the token balance arrive as human decimal strings, so comparing them at a
|
// the token balance arrive as human decimal strings, so comparing them at a
|
||||||
// common scale is exact.
|
// common scale is exact.
|
||||||
|
|
||||||
const { parseUnits } = require("ethers");
|
const { parseUnits, formatEther } = require("ethers");
|
||||||
|
|
||||||
const SCALE_DECIMALS = 18;
|
const SCALE_DECIMALS = 18;
|
||||||
|
|
||||||
@@ -87,6 +87,28 @@ function toFixedPoint(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The most ETH a send can carry: the exact balance minus the fee reserve from
|
||||||
|
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
|
||||||
|
// exactly that reserve left behind. `ethBalance` is the exact decimal string
|
||||||
|
// balances.js stores, never a rounded one. Null when the balance does not
|
||||||
|
// leave anything to send once the fee is paid, or when either input is
|
||||||
|
// unusable.
|
||||||
|
function maxEthAmount(ethBalance, feeWei) {
|
||||||
|
const balanceWei = toFixedPoint(ethBalance);
|
||||||
|
if (balanceWei === null) return null;
|
||||||
|
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
|
||||||
|
const amountWei = balanceWei - feeWei;
|
||||||
|
if (amountWei <= 0n) return null;
|
||||||
|
return formatEther(amountWei);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most of a token a send can carry: its balance cut down, never rounded
|
||||||
|
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
|
||||||
|
// declare more than 18 decimals, and its balance is stored with all of them.
|
||||||
|
function maxTokenAmount(tokenBalance) {
|
||||||
|
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate a pending transfer against the balances that must cover it.
|
// Validate a pending transfer against the balances that must cover it.
|
||||||
//
|
//
|
||||||
// isErc20 — token transfer rather than a native ETH transfer
|
// isErc20 — token transfer rather than a native ETH transfer
|
||||||
@@ -139,13 +161,12 @@ function validateTransfer({
|
|||||||
const feeFp = known ? feeWei : null;
|
const feeFp = known ? feeWei : null;
|
||||||
|
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
// A token can declare more than 18 decimals, and its balance is
|
// Only the first 18 places of the balance are read: an amount with
|
||||||
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
|
// more was refused above, so the places after them cannot decide
|
||||||
// are read: an amount with more was refused above, so the places
|
// whether the amount fits.
|
||||||
// after them cannot decide whether the amount fits.
|
|
||||||
const tokenText =
|
const tokenText =
|
||||||
typeof tokenBalance === "string"
|
typeof tokenBalance === "string"
|
||||||
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
|
? maxTokenAmount(tokenBalance)
|
||||||
: tokenBalance;
|
: tokenBalance;
|
||||||
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
||||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||||
@@ -174,6 +195,8 @@ module.exports = {
|
|||||||
SCALE_DECIMALS,
|
SCALE_DECIMALS,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
|
maxEthAmount,
|
||||||
|
maxTokenAmount,
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
};
|
};
|
||||||
|
|||||||
+47
-15
@@ -84,17 +84,31 @@ function present(value) {
|
|||||||
//
|
//
|
||||||
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
||||||
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
||||||
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
|
// a zero minimum is a literal zero slippage floor and is stated as one. Nor
|
||||||
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
||||||
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
||||||
// so a zero `amountIn` there is a literal zero and is displayed as one.
|
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
|
||||||
|
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
|
||||||
const OPEN_DELTA = Symbol("v4-open-delta");
|
const OPEN_DELTA = Symbol("v4-open-delta");
|
||||||
|
|
||||||
// The two amount lines that state a fact instead of a quantity. Same register
|
// The Universal Router's V2 exact-in spells "the pair already holds the input
|
||||||
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
|
// tokens" as an amount of zero: universal-router
|
||||||
// as a number — and deliberately not a third phrasing of "not named": these
|
// `contracts/libraries/Constants.sol` declares
|
||||||
// say different things.
|
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
|
||||||
|
// when a v2 pair has already received input tokens"), and
|
||||||
|
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
|
||||||
|
// equals it. The swap then spends whatever an earlier step sent to the pair.
|
||||||
|
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
|
||||||
|
// that nothing is swapped.
|
||||||
|
const ALREADY_PAID = Symbol("v2-already-paid");
|
||||||
|
|
||||||
|
// The amount lines that state a fact instead of a quantity. Same register as
|
||||||
|
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
|
||||||
|
// number — and deliberately not another phrasing of "not named": these say
|
||||||
|
// different things.
|
||||||
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
||||||
|
const ALREADY_PAID_AMOUNT =
|
||||||
|
"Whatever an earlier step sent to the pair (V2 already paid)";
|
||||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||||
|
|
||||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
||||||
@@ -185,6 +199,7 @@ function decodeBalanceCheck(input) {
|
|||||||
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
|
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
|
||||||
function decodeV2SwapExactIn(input) {
|
function decodeV2SwapExactIn(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -192,7 +207,7 @@ function decodeV2SwapExactIn(input) {
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
amountIn: d[1],
|
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
||||||
amountOutMin: d[2],
|
amountOutMin: d[2],
|
||||||
tokenIn: d[3][0],
|
tokenIn: d[3][0],
|
||||||
tokenOut: d[3][d[3].length - 1],
|
tokenOut: d[3][d[3].length - 1],
|
||||||
@@ -502,7 +517,13 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
if (cmdId === 0x0e) {
|
if (cmdId === 0x0e) {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
if (b) setOutput(b.token, b.minBalance);
|
// The router passes this check whenever the owner holds at
|
||||||
|
// least minBalance, so a zero one guarantees nothing and
|
||||||
|
// does not replace a minimum an earlier step stated. Any
|
||||||
|
// other minBalance sets the output side as a swap does.
|
||||||
|
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
||||||
|
setOutput(b.token, b.minBalance);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x00) {
|
if (cmdId === 0x00) {
|
||||||
@@ -623,14 +644,20 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (present(inputAmount)) {
|
if (present(inputAmount)) {
|
||||||
// Two amounts need no scale to describe and are named rather than
|
// Three amounts need no scale to describe and are named rather
|
||||||
// formatted: V4's open delta, which is not a quantity at all (see
|
// than formatted: V4's open delta and V2's already-paid zero,
|
||||||
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
// neither of which is a quantity at all (see OPEN_DELTA and
|
||||||
// first — the sentinel is not a bigint and cannot be compared with
|
// ALREADY_PAID), and an unbounded permit. Those two tests come
|
||||||
// one.
|
// first — the sentinels are not bigints and cannot be compared
|
||||||
|
// with one.
|
||||||
let amount;
|
let amount;
|
||||||
if (inputAmount === OPEN_DELTA) {
|
if (inputAmount === OPEN_DELTA) {
|
||||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||||
|
} else if (inputAmount === ALREADY_PAID) {
|
||||||
|
amount = {
|
||||||
|
raw: ALREADY_PAID_AMOUNT,
|
||||||
|
display: ALREADY_PAID_AMOUNT,
|
||||||
|
};
|
||||||
} else if (inputAmount >= MAX_UINT160) {
|
} else if (inputAmount >= MAX_UINT160) {
|
||||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
amount = { raw: "Unlimited", display: "Unlimited" };
|
||||||
} else if (hasV2ExactOut) {
|
} else if (hasV2ExactOut) {
|
||||||
@@ -697,10 +724,15 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
||||||
|
|
||||||
|
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
|
||||||
|
// later deadline, such as the uint256 maximum, makes an invalid date,
|
||||||
|
// and toISOString() throws on one, so that deadline is said in words.
|
||||||
const deadlineDate = new Date(Number(deadline) * 1000);
|
const deadlineDate = new Date(Number(deadline) * 1000);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Deadline",
|
label: "Deadline",
|
||||||
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
value: isNaN(deadlineDate.getTime())
|
||||||
|
? "After 275760-09-13 00:00:00 (no deadline in practice)"
|
||||||
|
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,11 +31,15 @@ const iface = new Interface(ERC20_ABI);
|
|||||||
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
||||||
// In the bundled list, at 6 decimals.
|
// In the bundled list, at 6 decimals.
|
||||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
|
// In the bundled list, at 0 decimals.
|
||||||
|
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const SPENDER = "0x1111111111111111111111111111111111111111";
|
const SPENDER = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
// 5,000 units of a 6-decimal token, the amount from the issue.
|
// 5,000 units of a 6-decimal token, the amount from the issue.
|
||||||
const FIVE_THOUSAND_AT_SIX = 5000000000n;
|
const FIVE_THOUSAND_AT_SIX = 5000000000n;
|
||||||
|
// 5,000 units of a 0-decimal token, which are 5,000 tokens.
|
||||||
|
const FIVE_THOUSAND_AT_ZERO = 5000n;
|
||||||
const MAX_UINT256 = (1n << 256n) - 1n;
|
const MAX_UINT256 = (1n << 256n) - 1n;
|
||||||
|
|
||||||
function transferData(amount) {
|
function transferData(amount) {
|
||||||
@@ -113,6 +117,21 @@ describe("resolveTokenDecimals", () => {
|
|||||||
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zero decimals is a real scale, not a missing one, so a source that
|
||||||
|
// answers 0 is used rather than fallen past like the unusable entry above.
|
||||||
|
test("uses a bundled scale of zero", () => {
|
||||||
|
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
|
||||||
|
expect(resolveTokenDecimals(SLP, state)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a tracked scale of zero", () => {
|
||||||
|
state.trackedTokens = [
|
||||||
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
|
||||||
|
];
|
||||||
|
state.wallets = walletsHolding(NOVEL_TOKEN, 18);
|
||||||
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("refuses a scale the explorer's own entries disagree about", () => {
|
test("refuses a scale the explorer's own entries disagree about", () => {
|
||||||
const wallets = walletsHolding(NOVEL_TOKEN, 6);
|
const wallets = walletsHolding(NOVEL_TOKEN, 6);
|
||||||
wallets[0].addresses.push({
|
wallets[0].addresses.push({
|
||||||
@@ -184,6 +203,22 @@ describe("decodeCalldata amount", () => {
|
|||||||
expect(line).not.toMatch(/0\.0000/);
|
expect(line).not.toMatch(/0\.0000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A token added by hand carries whatever its decimals() returned, and a
|
||||||
|
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
|
||||||
|
// call undecoded rather than refused
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
||||||
|
test("a token reporting more than 80 decimals shows base units", () => {
|
||||||
|
state.trackedTokens = [
|
||||||
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 81 },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||||
|
).toBe("5000000000 base units (decimals unknown)");
|
||||||
|
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
||||||
|
"5000000000 base units (decimals unknown)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("an unbounded allowance is still named, with or without a scale", () => {
|
test("an unbounded allowance is still named, with or without a scale", () => {
|
||||||
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
||||||
"Unlimited",
|
"Unlimited",
|
||||||
@@ -197,6 +232,21 @@ describe("decodeCalldata amount", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a bundled token with zero decimals shows the true quantity", () => {
|
||||||
|
expect(amountLine(transferData(FIVE_THOUSAND_AT_ZERO), SLP)).toBe(
|
||||||
|
"5000.0000 SLP",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tracked token with zero decimals shows the true quantity", () => {
|
||||||
|
state.trackedTokens = [
|
||||||
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
amountLine(transferData(FIVE_THOUSAND_AT_ZERO), NOVEL_TOKEN),
|
||||||
|
).toBe("5000.0000 NOVEL");
|
||||||
|
});
|
||||||
|
|
||||||
test("the amount carried to the status screens is the same string", () => {
|
test("the amount carried to the status screens is the same string", () => {
|
||||||
const decoded = decodeCalldata(
|
const decoded = decodeCalldata(
|
||||||
transferData(FIVE_THOUSAND_AT_SIX),
|
transferData(FIVE_THOUSAND_AT_SIX),
|
||||||
|
|||||||
@@ -599,6 +599,24 @@ describe("verifySignedTx field comparison", () => {
|
|||||||
expect(e.message).toContain("1.0 ETH");
|
expect(e.message).toContain("1.0 ETH");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The fee is in the native currency of the network the transaction is
|
||||||
|
// for, whether its chain id is the hex string the background prepares
|
||||||
|
// or the number ethers parses from a signed transaction.
|
||||||
|
test.each([
|
||||||
|
["0x1", "ETH"],
|
||||||
|
[1n, "ETH"],
|
||||||
|
["0xaa36a7", "SepoliaETH"],
|
||||||
|
[11155111n, "SepoliaETH"],
|
||||||
|
])("the refusal on chain %p names %s", (chainId, nativeCurrency) => {
|
||||||
|
expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow(
|
||||||
|
"up to 3000.0 " +
|
||||||
|
nativeCurrency +
|
||||||
|
", which is more than the 1.0 " +
|
||||||
|
nativeCurrency +
|
||||||
|
" this wallet",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every field mismatch is a refusal, not a warning", async () => {
|
test("every field mismatch is a refusal, not a warning", async () => {
|
||||||
|
|||||||
@@ -2235,6 +2235,27 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The prompt is decided before the toolbar popup raised for it has
|
||||||
|
// loaded; that popup is torn down and openPopup() rejects only after.
|
||||||
|
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
|
||||||
|
const bg = loadBackground({ actionPopup: true });
|
||||||
|
const opening = deferred();
|
||||||
|
bg.openPopup.mockImplementation(() => opening.promise);
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id());
|
||||||
|
port.decide(true, false);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||||
|
|
||||||
|
opening.reject(new Error("the toolbar popup closed before it loaded"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(bg.created).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
// The port carries a decision now, so it carries the sender check the
|
// The port carries a decision now, so it carries the sender check the
|
||||||
// one-off message used to carry. A content script that guessed an
|
// one-off message used to carry. A content script that guessed an
|
||||||
// approval id must not be able to connect the site it is running on.
|
// approval id must not be able to connect the site it is running on.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
|
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
|
||||||
// the request.
|
// the request.
|
||||||
|
|
||||||
const { debugFetch, setRuntimeDebug } = require("../src/shared/log");
|
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -42,3 +42,12 @@ test("logs the origin and JSON-RPC method, not the key in the URL", async () =>
|
|||||||
expect(logged).toContain("https://rpc.example.invalid");
|
expect(logged).toContain("https://rpc.example.invalid");
|
||||||
expect(logged).toContain("eth_chainId");
|
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,6 +46,9 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|||||||
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
||||||
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
|
|
||||||
|
// U+200B, built from its code point so that it can be seen in this file.
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
||||||
|
|
||||||
// ------------------------------------------------------------ DOM stub
|
// ------------------------------------------------------------ DOM stub
|
||||||
|
|
||||||
function makeElement(id) {
|
function makeElement(id) {
|
||||||
@@ -342,6 +345,72 @@ describe("the typed confirmation", () => {
|
|||||||
"secret-three",
|
"secret-three",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A name of only spaces compares as nothing, and so does an empty
|
||||||
|
// field. Typing nothing must still delete nothing.
|
||||||
|
test.each(["", " "])(
|
||||||
|
"typing %j deletes nothing when the name is only spaces",
|
||||||
|
async (typedValue) => {
|
||||||
|
const { deleteWallet, state, storage } = load();
|
||||||
|
state.wallets[1].name = " ";
|
||||||
|
await openLostPassword(deleteWallet, 1);
|
||||||
|
|
||||||
|
node("delete-wallet-lost-name-input").value = typedValue;
|
||||||
|
await click("btn-delete-wallet-lost-confirm");
|
||||||
|
|
||||||
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
||||||
|
"visible",
|
||||||
|
);
|
||||||
|
expect(state.wallets).toHaveLength(3);
|
||||||
|
expect(await persistedWallets(storage)).toHaveLength(3);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// A name that shows nothing would leave nothing on screen to type
|
||||||
|
// back, so the screen names the wallet by its position instead, and
|
||||||
|
// that is what the user types.
|
||||||
|
test.each([
|
||||||
|
["spaces", " "],
|
||||||
|
["a zero-width space", ZERO_WIDTH_SPACE],
|
||||||
|
])(
|
||||||
|
"a name of only %s is shown and typed back as Wallet 2",
|
||||||
|
async (_label, storedName) => {
|
||||||
|
const { deleteWallet, state, storage } = load();
|
||||||
|
state.wallets[1].name = storedName;
|
||||||
|
await openLostPassword(deleteWallet, 1);
|
||||||
|
|
||||||
|
expect(node("delete-wallet-lost-name").textContent).toBe(
|
||||||
|
"Wallet 2",
|
||||||
|
);
|
||||||
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
||||||
|
await click("btn-delete-wallet-lost-confirm");
|
||||||
|
|
||||||
|
const persisted = await persistedWallets(storage);
|
||||||
|
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
||||||
|
"secret-one",
|
||||||
|
"secret-three",
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// A zero-width space paints nothing, so "My", a zero-width space and
|
||||||
|
// "Wallet" reads as "MyWallet", and that is all the user can type. HTML
|
||||||
|
// does not collapse it the way it collapses spaces, so it has to be
|
||||||
|
// removed explicitly.
|
||||||
|
test("a zero-width space inside the name is not part of it", async () => {
|
||||||
|
const { deleteWallet, state, storage } = load();
|
||||||
|
state.wallets[1].name = "My" + ZERO_WIDTH_SPACE + "Wallet";
|
||||||
|
await openLostPassword(deleteWallet, 1);
|
||||||
|
|
||||||
|
node("delete-wallet-lost-name-input").value = "MyWallet";
|
||||||
|
await click("btn-delete-wallet-lost-confirm");
|
||||||
|
|
||||||
|
const persisted = await persistedWallets(storage);
|
||||||
|
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
||||||
|
"secret-one",
|
||||||
|
"secret-three",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleting without the password", () => {
|
describe("deleting without the password", () => {
|
||||||
|
|||||||
+168
-34
@@ -204,40 +204,51 @@ async function goHome(page) {
|
|||||||
await visible(page, "#view-main");
|
await visible(page, "#view-main");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The navigation stack as it was actually persisted, read out of extension
|
// One field of the popup's state as it was actually persisted, read out of
|
||||||
// storage rather than inferred from which screen is showing. A stale entry
|
// extension storage rather than inferred from what is on screen.
|
||||||
// left behind by a forward navigation that threw is invisible on screen
|
function persistedField(page, field) {
|
||||||
// until the user presses Back one time too many — which is exactly the
|
|
||||||
// second-order damage #150 did — so the stack itself is what gets asserted.
|
|
||||||
function persistedViewStack(page) {
|
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() =>
|
(key) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
chrome.storage.local.get("autistmask", (r) => {
|
chrome.storage.local.get("autistmask", (r) => {
|
||||||
resolve((r.autistmask && r.autistmask.viewStack) || []);
|
resolve(r.autistmask ? r.autistmask[key] : undefined);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
field,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState() is fired from showView() without being awaited, so the write
|
// The navigation stack as it was actually persisted. A stale entry left
|
||||||
// lands shortly after the screen does. Polling for the expected stack keeps
|
// behind by a forward navigation that threw is invisible on screen until
|
||||||
// that race out of the assertion; a stack that never becomes the expected
|
// the user presses Back one time too many — which is exactly the
|
||||||
// one fails with what it actually was.
|
// second-order damage #150 did — so the stack itself is what gets asserted.
|
||||||
const VIEW_STACK_SETTLE_MS = 5000;
|
async function persistedViewStack(page) {
|
||||||
|
return (await persistedField(page, "viewStack")) || [];
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForViewStack(page, expected, where) {
|
// Nothing here can await the popup's saves. showView() fires saveState()
|
||||||
|
// without awaiting it, and a Settings control's "change" handler awaits its
|
||||||
|
// save only after click() or selectOption() has already returned. So the
|
||||||
|
// write lands shortly after the screen or the control changes, and a popup
|
||||||
|
// closed before then loses it. Polling for the expected value keeps that
|
||||||
|
// race out of the assertion and out of the close; a value that never
|
||||||
|
// arrives fails with what it actually was.
|
||||||
|
const SAVE_SETTLE_MS = 5000;
|
||||||
|
|
||||||
|
async function waitForPersisted(page, field, expected, where) {
|
||||||
const want = JSON.stringify(expected);
|
const want = JSON.stringify(expected);
|
||||||
const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
|
const deadline = Date.now() + SAVE_SETTLE_MS;
|
||||||
let seen;
|
let seen;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
seen = await persistedViewStack(page);
|
seen = await persistedField(page, field);
|
||||||
if (JSON.stringify(seen) === want) return;
|
if (JSON.stringify(seen) === want) return;
|
||||||
if (Date.now() >= deadline) break;
|
if (Date.now() >= deadline) break;
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"navigation stack " +
|
"the persisted " +
|
||||||
|
field +
|
||||||
|
" " +
|
||||||
where +
|
where +
|
||||||
" is " +
|
" is " +
|
||||||
JSON.stringify(seen) +
|
JSON.stringify(seen) +
|
||||||
@@ -257,12 +268,18 @@ test("Back from Add Token unwinds the stack exactly once (#150)", async (env) =>
|
|||||||
|
|
||||||
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
||||||
await visible(env.page, "#view-address");
|
await visible(env.page, "#view-address");
|
||||||
await waitForViewStack(env.page, base.concat("main"), "on address detail");
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"viewStack",
|
||||||
|
base.concat("main"),
|
||||||
|
"on address detail",
|
||||||
|
);
|
||||||
|
|
||||||
await env.page.click("#btn-add-token");
|
await env.page.click("#btn-add-token");
|
||||||
await visible(env.page, "#view-add-token");
|
await visible(env.page, "#view-add-token");
|
||||||
await waitForViewStack(
|
await waitForPersisted(
|
||||||
env.page,
|
env.page,
|
||||||
|
"viewStack",
|
||||||
base.concat("main", "address"),
|
base.concat("main", "address"),
|
||||||
"on the add token screen",
|
"on the add token screen",
|
||||||
);
|
);
|
||||||
@@ -273,15 +290,16 @@ test("Back from Add Token unwinds the stack exactly once (#150)", async (env) =>
|
|||||||
!(await env.page.isVisible("#view-add-token")),
|
!(await env.page.isVisible("#view-add-token")),
|
||||||
"the add token screen is still showing after Back",
|
"the add token screen is still showing after Back",
|
||||||
);
|
);
|
||||||
await waitForViewStack(
|
await waitForPersisted(
|
||||||
env.page,
|
env.page,
|
||||||
|
"viewStack",
|
||||||
base.concat("main"),
|
base.concat("main"),
|
||||||
"after Back from add token",
|
"after Back from add token",
|
||||||
);
|
);
|
||||||
|
|
||||||
await env.page.click("#btn-address-back");
|
await env.page.click("#btn-address-back");
|
||||||
await visible(env.page, "#view-main");
|
await visible(env.page, "#view-main");
|
||||||
await waitForViewStack(env.page, base, "after a second Back");
|
await waitForPersisted(env.page, "viewStack", base, "after a second Back");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
|
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
|
||||||
@@ -679,6 +697,12 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
|||||||
await openPhraseScreen(env.page);
|
await openPhraseScreen(env.page);
|
||||||
await revealPhrase(env.page);
|
await revealPhrase(env.page);
|
||||||
|
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"currentView",
|
||||||
|
"show-phrase",
|
||||||
|
"before closing the popup",
|
||||||
|
);
|
||||||
await env.page.close();
|
await env.page.close();
|
||||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||||
await visible(env.page, "#view-main");
|
await visible(env.page, "#view-main");
|
||||||
@@ -714,10 +738,20 @@ function addressScreenState(page) {
|
|||||||
|
|
||||||
// Close and reopen the page rather than reload it: that is what the toolbar
|
// Close and reopen the page rather than reload it: that is what the toolbar
|
||||||
// popup does, and it is the only thing that produces the unrendered views.
|
// popup does, and it is the only thing that produces the unrendered views.
|
||||||
async function reopenPopup(env, restoredView) {
|
// The popup reopens on the view it last saved, so the close waits until
|
||||||
|
// `view` is the one saved. That wait cannot see a save that leaves the value
|
||||||
|
// as it was: a caller whose popup already had `view` saved waits for a
|
||||||
|
// screen in between first.
|
||||||
|
async function reopenPopup(env, view) {
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"currentView",
|
||||||
|
view,
|
||||||
|
"before closing the popup",
|
||||||
|
);
|
||||||
await env.page.close();
|
await env.page.close();
|
||||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||||
await visible(env.page, restoredView);
|
await visible(env.page, "#view-" + view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reproduction from the issue, step for step.
|
// The reproduction from the issue, step for step.
|
||||||
@@ -732,7 +766,7 @@ test("Back after reopening the popup renders the address screen (#268)", async (
|
|||||||
await env.page.click("#btn-settings");
|
await env.page.click("#btn-settings");
|
||||||
await visible(env.page, "#view-settings");
|
await visible(env.page, "#view-settings");
|
||||||
|
|
||||||
await reopenPopup(env, "#view-settings");
|
await reopenPopup(env, "settings");
|
||||||
|
|
||||||
await env.page.click("#btn-settings-back");
|
await env.page.click("#btn-settings-back");
|
||||||
await visible(env.page, "#view-address");
|
await visible(env.page, "#view-address");
|
||||||
@@ -789,10 +823,18 @@ test("Back after reopening the popup renders the Receive screen (#268)", async (
|
|||||||
JSON.stringify(before.address),
|
JSON.stringify(before.address),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The test above left `settings` saved too, so reopenPopup() could not
|
||||||
|
// tell this page's save of it from that one without a save in between.
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"currentView",
|
||||||
|
"receive",
|
||||||
|
"on the Receive screen",
|
||||||
|
);
|
||||||
await env.page.click("#btn-settings");
|
await env.page.click("#btn-settings");
|
||||||
await visible(env.page, "#view-settings");
|
await visible(env.page, "#view-settings");
|
||||||
|
|
||||||
await reopenPopup(env, "#view-settings");
|
await reopenPopup(env, "settings");
|
||||||
|
|
||||||
await env.page.click("#btn-settings-back");
|
await env.page.click("#btn-settings-back");
|
||||||
await visible(env.page, "#view-receive");
|
await visible(env.page, "#view-receive");
|
||||||
@@ -1175,11 +1217,24 @@ const NONDEFAULT_NETWORK = "sepolia";
|
|||||||
test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
|
test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
|
||||||
await openSettings(env.page);
|
await openSettings(env.page);
|
||||||
|
|
||||||
// selectOption() fires "change", which is what the handlers bind.
|
// selectOption() fires "change", which is what the handlers bind. It
|
||||||
|
// returns before the handler's save lands, hence each wait.
|
||||||
await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
|
await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"theme",
|
||||||
|
NONDEFAULT_THEME,
|
||||||
|
"after the switch",
|
||||||
|
);
|
||||||
await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);
|
await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"networkId",
|
||||||
|
NONDEFAULT_NETWORK,
|
||||||
|
"after the switch",
|
||||||
|
);
|
||||||
|
|
||||||
await reopenPopup(env, "#view-settings");
|
await reopenPopup(env, "settings");
|
||||||
|
|
||||||
assertSelectors(
|
assertSelectors(
|
||||||
await selectorValues(env.page),
|
await selectorValues(env.page),
|
||||||
@@ -1198,9 +1253,16 @@ test("the theme and network selectors carry a non-default persisted value (#229)
|
|||||||
// fixture never customised and so are the mainnet defaults
|
// fixture never customised and so are the mainnet defaults
|
||||||
// src/shared/state.js starts with.
|
// src/shared/state.js starts with.
|
||||||
await env.page.selectOption("#settings-theme", "system");
|
await env.page.selectOption("#settings-theme", "system");
|
||||||
|
await waitForPersisted(env.page, "theme", "system", "after the restore");
|
||||||
await env.page.selectOption("#settings-network", "mainnet");
|
await env.page.selectOption("#settings-network", "mainnet");
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"networkId",
|
||||||
|
"mainnet",
|
||||||
|
"after the restore",
|
||||||
|
);
|
||||||
|
|
||||||
await reopenPopup(env, "#view-settings");
|
await reopenPopup(env, "settings");
|
||||||
|
|
||||||
assertSelectors(
|
assertSelectors(
|
||||||
await selectorValues(env.page),
|
await selectorValues(env.page),
|
||||||
@@ -1225,8 +1287,14 @@ test("a spam filter toggled in Settings survives a popup reopen (#229)", async (
|
|||||||
immediately.checked === false,
|
immediately.checked === false,
|
||||||
"clicking #" + TOGGLED_FILTER + " did not clear it",
|
"clicking #" + TOGGLED_FILTER + " did not clear it",
|
||||||
);
|
);
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"hideDustTransactions",
|
||||||
|
false,
|
||||||
|
"after clearing #" + TOGGLED_FILTER,
|
||||||
|
);
|
||||||
|
|
||||||
await reopenPopup(env, "#view-settings");
|
await reopenPopup(env, "settings");
|
||||||
|
|
||||||
const after = await checkboxStates(env.page);
|
const after = await checkboxStates(env.page);
|
||||||
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
||||||
@@ -1243,8 +1311,14 @@ test("a spam filter toggled in Settings survives a popup reopen (#229)", async (
|
|||||||
test("turning the same filter back on survives a reopen too (#229)", async (env) => {
|
test("turning the same filter back on survives a reopen too (#229)", async (env) => {
|
||||||
await openSettings(env.page);
|
await openSettings(env.page);
|
||||||
await env.page.click("#" + TOGGLED_FILTER);
|
await env.page.click("#" + TOGGLED_FILTER);
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"hideDustTransactions",
|
||||||
|
true,
|
||||||
|
"after setting #" + TOGGLED_FILTER + " again",
|
||||||
|
);
|
||||||
|
|
||||||
await reopenPopup(env, "#view-settings");
|
await reopenPopup(env, "settings");
|
||||||
|
|
||||||
// Restores the fixture the later sections inherit, and rules out a
|
// Restores the fixture the later sections inherit, and rules out a
|
||||||
// checkbox that persists "off" only because it is stuck there.
|
// checkbox that persists "off" only because it is stuck there.
|
||||||
@@ -2365,7 +2439,7 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
|
|||||||
|
|
||||||
// Close and reopen so the refresh that runs on open fetches balances
|
// Close and reopen so the refresh that runs on open fetches balances
|
||||||
// with the hostile symbol in them.
|
// with the hostile symbol in them.
|
||||||
await reopenPopup(env, "#view-address");
|
await reopenPopup(env, "address");
|
||||||
await env.page.waitForFunction(
|
await env.page.waitForFunction(
|
||||||
(addr) =>
|
(addr) =>
|
||||||
!!document.querySelector(
|
!!document.querySelector(
|
||||||
@@ -2431,7 +2505,7 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
|
|||||||
// Put the fixture back before the next test reads it, and let the
|
// Put the fixture back before the next test reads it, and let the
|
||||||
// stored balances be rewritten with the honest symbol.
|
// stored balances be rewritten with the honest symbol.
|
||||||
env.routeOpts.tokenSymbolOverride = null;
|
env.routeOpts.tokenSymbolOverride = null;
|
||||||
await reopenPopup(env, "#view-main");
|
await reopenPopup(env, "main");
|
||||||
await env.page.waitForFunction(
|
await env.page.waitForFunction(
|
||||||
(addr) => {
|
(addr) => {
|
||||||
const row = document.querySelector(
|
const row = document.querySelector(
|
||||||
@@ -2738,7 +2812,7 @@ async function closeApprovalPages(ctx) {
|
|||||||
// #btn-reject on the site prompt — NOT self-proving. A page that went away
|
// #btn-reject on the site prompt — NOT self-proving. A page that went away
|
||||||
// without the click landing disconnects the approval port, the background
|
// without the click landing disconnects the approval port, the background
|
||||||
// settles that as 4001, and 4001 is exactly what assertUserRejection
|
// settles that as 4001, and 4001 is exactly what assertUserRejection
|
||||||
// accepts. That call site arms the click trace below and asserts it.
|
// accepts. Both call sites arm the click trace below and assert it.
|
||||||
//
|
//
|
||||||
// A button that is missing or unclickable raises a different error, which is
|
// A button that is missing or unclickable raises a different error, which is
|
||||||
// rethrown.
|
// rethrown.
|
||||||
@@ -3124,7 +3198,9 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
|
|||||||
// Not remembered: a remembered decision for this origin would
|
// Not remembered: a remembered decision for this origin would
|
||||||
// outlive the test.
|
// outlive the test.
|
||||||
await popup.uncheck("#approve-remember");
|
await popup.uncheck("#approve-remember");
|
||||||
await popup.click("#btn-reject");
|
await armClickTrace(env, popup, "#btn-reject");
|
||||||
|
await clickAndClose(popup, "#btn-reject");
|
||||||
|
await assertClickLanded(env, "#btn-reject");
|
||||||
|
|
||||||
await assertUserRejection(
|
await assertUserRejection(
|
||||||
phishingDapp,
|
phishingDapp,
|
||||||
@@ -3232,6 +3308,64 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A right-to-left character must not move the characters around it: U+05C3
|
||||||
|
// between "5" and "00" would otherwise put "500" on screen before it. A
|
||||||
|
// paragraph separator (U+2029) before it, left in the text, would end the
|
||||||
|
// byte-order layout and bring that back
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/403).
|
||||||
|
test("a personal message is laid out in the order of its bytes (#403)", async (env) => {
|
||||||
|
const rightToLeft = String.fromCodePoint(0x05c3);
|
||||||
|
const text =
|
||||||
|
"Sign in" +
|
||||||
|
String.fromCodePoint(0x2029) +
|
||||||
|
"Pay 5" +
|
||||||
|
rightToLeft +
|
||||||
|
"00 ETH";
|
||||||
|
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
|
||||||
|
hexlify(toUtf8Bytes(text)),
|
||||||
|
env.expectedAddress,
|
||||||
|
]);
|
||||||
|
const popup = await waitForApprovalWindow(env.ctx);
|
||||||
|
await visible(popup, "#view-approve-sign");
|
||||||
|
|
||||||
|
// The text on screen, marks included, and the left edge of each of its
|
||||||
|
// characters, in byte order. A character the browser's fonts draw with
|
||||||
|
// no width shares its neighbour's edge.
|
||||||
|
const shown = await popup.evaluate(() => {
|
||||||
|
const message = document.getElementById("approve-sign-message");
|
||||||
|
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
|
||||||
|
const range = document.createRange();
|
||||||
|
let text = "";
|
||||||
|
const lefts = [];
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
for (let i = 0; i < node.length; i++) {
|
||||||
|
range.setStart(node, i);
|
||||||
|
range.setEnd(node, i + 1);
|
||||||
|
lefts.push(range.getBoundingClientRect().left);
|
||||||
|
}
|
||||||
|
text += node.data;
|
||||||
|
}
|
||||||
|
return { text, lefts };
|
||||||
|
});
|
||||||
|
await clickAndClose(popup, "#btn-reject-sign");
|
||||||
|
await assertUserRejection(
|
||||||
|
env.dapp,
|
||||||
|
"sign-bidi",
|
||||||
|
"the byte-order personal_sign rejection",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
shown.text === "Sign inU+2029Pay 5" + rightToLeft + "00 ETH",
|
||||||
|
"the paragraph separator is not shown as a mark: " +
|
||||||
|
JSON.stringify(shown.text),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
shown.lefts.every((left, i) => i === 0 || left >= shown.lefts[i - 1]),
|
||||||
|
"the personal message is not laid out in byte order: " +
|
||||||
|
JSON.stringify(shown.lefts),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
||||||
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
||||||
env.expectedAddress,
|
env.expectedAddress,
|
||||||
|
|||||||
@@ -57,6 +57,39 @@ describe("parseHoldersCount", () => {
|
|||||||
expect(parseHoldersCount("many")).toBeNull();
|
expect(parseHoldersCount("many")).toBeNull();
|
||||||
expect(parseHoldersCount(NaN)).toBeNull();
|
expect(parseHoldersCount(NaN)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Each of these starts with a digit, so reading only the leading digits
|
||||||
|
// would turn it into a small reported count, and a small count is
|
||||||
|
// exactly what hides a token as spam (issue #251).
|
||||||
|
test.each(["1,000", "0x10", "1e3", "12 holders"])(
|
||||||
|
"%p is not read in part: it is unknown",
|
||||||
|
(raw) => {
|
||||||
|
expect(parseHoldersCount(raw)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a negative count is unknown", () => {
|
||||||
|
expect(parseHoldersCount("-5")).toBeNull();
|
||||||
|
expect(parseHoldersCount(-5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
|
||||||
|
// string of digits would come back rounded, and a long enough one as
|
||||||
|
// Infinity, which would pass every holder-count floor.
|
||||||
|
test("a count too large for a number to hold exactly is unknown", () => {
|
||||||
|
expect(parseHoldersCount("9007199254740993")).toBeNull();
|
||||||
|
expect(parseHoldersCount("9".repeat(400))).toBeNull();
|
||||||
|
expect(parseHoldersCount(2 ** 53)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the largest count a number holds exactly still parses", () => {
|
||||||
|
expect(parseHoldersCount("9007199254740991")).toBe(
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
);
|
||||||
|
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isLowHolderCount", () => {
|
describe("isLowHolderCount", () => {
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
// The native token's label on the screens that show a native amount.
|
||||||
|
//
|
||||||
|
// src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet
|
||||||
|
// and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a
|
||||||
|
// hardcoded "ETH", so on Sepolia the balance, the value and the fee all read
|
||||||
|
// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted
|
||||||
|
// on both networks, through the real Send, confirmation and approval screens,
|
||||||
|
// with only the node and the DOM stubbed. So is that a token cannot pass for
|
||||||
|
// the native token by reporting its label, and that a transaction's figures
|
||||||
|
// carry its own network's label when another network is active.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
jest.mock("ethers", () => {
|
||||||
|
const actual = jest.requireActual("ethers");
|
||||||
|
class StubProvider {
|
||||||
|
async lookupAddress() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 10 gwei expected, 20 gwei reserved per gas.
|
||||||
|
async getFeeData() {
|
||||||
|
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
|
||||||
|
}
|
||||||
|
async estimateGas() {
|
||||||
|
return 21000n;
|
||||||
|
}
|
||||||
|
async getCode() {
|
||||||
|
return "0x";
|
||||||
|
}
|
||||||
|
async getTransactionCount() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
async getTransactionReceipt() {
|
||||||
|
return { blockNumber: 21000000 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
JsonRpcProvider: StubProvider,
|
||||||
|
Network: { from: () => ({}) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../src/shared/log", () => ({
|
||||||
|
log: {
|
||||||
|
debugf: () => {},
|
||||||
|
infof: () => {},
|
||||||
|
warnf: () => {},
|
||||||
|
errorf: () => {},
|
||||||
|
},
|
||||||
|
debugFetch: jest.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ items: [] }),
|
||||||
|
})),
|
||||||
|
urlOrigin: () => "",
|
||||||
|
setRuntimeDebug: () => {},
|
||||||
|
isDebug: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Signing a send succeeds without a key, and sending answers with a hash.
|
||||||
|
jest.mock("../src/shared/vault", () => ({
|
||||||
|
...jest.requireActual("../src/shared/vault"),
|
||||||
|
decryptWithPassword: async () => "secret",
|
||||||
|
}));
|
||||||
|
jest.mock("../src/shared/wallet", () => ({
|
||||||
|
...jest.requireActual("../src/shared/wallet"),
|
||||||
|
getSignerForAddress: () => ({
|
||||||
|
connect: () => ({
|
||||||
|
populateTransaction: async (request) => request,
|
||||||
|
sendTransaction: async () => ({ hash: "0x" + "3".repeat(64) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.fetch = jest.fn(() => {
|
||||||
|
throw new Error("tests must not perform network requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The approval the background hands the approval screen. Set per test.
|
||||||
|
let approvalDetails = null;
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
global.chrome = {
|
||||||
|
storage: makeStorageStub(),
|
||||||
|
runtime: {
|
||||||
|
connect: () => ({
|
||||||
|
postMessage() {},
|
||||||
|
disconnect() {},
|
||||||
|
onDisconnect: { addListener() {} },
|
||||||
|
}),
|
||||||
|
sendMessage(message, callback) {
|
||||||
|
callback(
|
||||||
|
message.type === "AUTISTMASK_GET_APPROVAL"
|
||||||
|
? approvalDetails
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// A stub DOM: every id resolves to a recording element.
|
||||||
|
const elements = new Map();
|
||||||
|
|
||||||
|
function makeEl(id) {
|
||||||
|
const handlers = new Map();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
innerHTML: "",
|
||||||
|
value: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add() {},
|
||||||
|
remove() {},
|
||||||
|
toggle() {},
|
||||||
|
contains: () => false,
|
||||||
|
},
|
||||||
|
handlers,
|
||||||
|
children: [],
|
||||||
|
addEventListener(name, fn) {
|
||||||
|
handlers.set(name, fn);
|
||||||
|
},
|
||||||
|
appendChild(child) {
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
querySelector: () => null,
|
||||||
|
remove() {},
|
||||||
|
focus() {},
|
||||||
|
// Views reach for .parentElement to hide whole sections.
|
||||||
|
get parentElement() {
|
||||||
|
return global.document.getElementById(id + "-parent");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
global.document = {
|
||||||
|
getElementById(id) {
|
||||||
|
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||||
|
return elements.get(id);
|
||||||
|
},
|
||||||
|
createElement: (tag) => makeEl(tag),
|
||||||
|
body: { prepend() {}, appendChild() {} },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
global.navigator = { clipboard: { writeText() {} } };
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
|
const { clearPrices } = require("../src/shared/prices");
|
||||||
|
const send = require("../src/popup/views/send");
|
||||||
|
const confirmTx = require("../src/popup/views/confirmTx");
|
||||||
|
const approval = require("../src/popup/views/approval");
|
||||||
|
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||||
|
const txStatus = require("../src/popup/views/txStatus");
|
||||||
|
const { balanceLinesForAddress } = require("../src/popup/views/helpers");
|
||||||
|
const { filterTransactions } = require("../src/shared/transactions");
|
||||||
|
const { debugFetch } = require("../src/shared/log");
|
||||||
|
|
||||||
|
const HOLDER = "0x" + "a".repeat(40);
|
||||||
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
|
// A token contract that is not in the bundled token list.
|
||||||
|
const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
||||||
|
|
||||||
|
function text(id) {
|
||||||
|
return global.document.getElementById(id).textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press Review on the Send screen for a native send of `amount`, and show the
|
||||||
|
// confirmation screen it leads to with its fee estimate settled.
|
||||||
|
async function confirmSend(amount) {
|
||||||
|
let txInfo = null;
|
||||||
|
send.init({ showConfirmTx: (info) => (txInfo = info) });
|
||||||
|
state.selectedToken = "ETH";
|
||||||
|
global.document.getElementById("send-to").value = RECIPIENT;
|
||||||
|
global.document.getElementById("send-amount").value = amount;
|
||||||
|
await global.document
|
||||||
|
.getElementById("btn-send-review")
|
||||||
|
.handlers.get("click")();
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The approval screen for a dApp transaction sending 0.01 of the native token
|
||||||
|
// with 21000 gas at up to 20 gwei, on the network with `chainId`.
|
||||||
|
async function approveTx(chainId) {
|
||||||
|
approvalDetails = {
|
||||||
|
type: "tx",
|
||||||
|
origin: "https://dapp.example",
|
||||||
|
approvedFrom: HOLDER,
|
||||||
|
approvedTx: {
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "10000000000000000",
|
||||||
|
data: "0x",
|
||||||
|
chainId,
|
||||||
|
gasLimit: "21000",
|
||||||
|
maxFeePerGas: "20000000000",
|
||||||
|
nonce: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await approval.show("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["mainnet", "ETH"],
|
||||||
|
["sepolia", "SepoliaETH"],
|
||||||
|
])("on %s the native token reads %s", (networkId, symbol) => {
|
||||||
|
beforeEach(() => {
|
||||||
|
elements.clear();
|
||||||
|
clearPrices();
|
||||||
|
state.networkId = networkId;
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
addresses: [{ address: HOLDER, balance: "1.5" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.fraudContracts = [];
|
||||||
|
state.currentView = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the balance", async () => {
|
||||||
|
const addr = state.wallets[0].addresses[0];
|
||||||
|
expect(balanceLinesForAddress(addr, [], false)).toContain(
|
||||||
|
`<span>${symbol}</span><span>1.5000</span>`,
|
||||||
|
);
|
||||||
|
state.selectedToken = "ETH";
|
||||||
|
send.updateSendBalance();
|
||||||
|
expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol);
|
||||||
|
await confirmSend("0.1");
|
||||||
|
expect(text("confirm-balance")).toBe("1.5000 " + symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the value", async () => {
|
||||||
|
await confirmSend("0.1");
|
||||||
|
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
|
||||||
|
expect(text("confirm-amount")).toBe("0.1 " + symbol);
|
||||||
|
await approveTx(NETWORKS[networkId].chainId);
|
||||||
|
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the fee", async () => {
|
||||||
|
await confirmSend("0.1");
|
||||||
|
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
|
||||||
|
expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol);
|
||||||
|
expect(text("confirm-fee-reserve")).toBe(
|
||||||
|
"up to 0.0004 " + symbol + " reserved",
|
||||||
|
);
|
||||||
|
expect(text("confirm-gas-error")).toContain(
|
||||||
|
"You do not have enough " + symbol + " to pay the network fee",
|
||||||
|
);
|
||||||
|
await approveTx(NETWORKS[networkId].chainId);
|
||||||
|
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the contract-recipient warning", async () => {
|
||||||
|
await confirmSend("0.1");
|
||||||
|
expect(text("confirm-contract-warning")).toContain(
|
||||||
|
"Sending " + symbol + " or tokens directly to a contract",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A token reports whatever symbol it likes. One reporting the label the
|
||||||
|
// wallet shows its native token under, on this network or any other, is
|
||||||
|
// a fake, exactly as one reporting `ETH` always was.
|
||||||
|
test.each(["ETH", symbol])(
|
||||||
|
"a token claiming %s is dropped from the history and the Send selector",
|
||||||
|
(claim) => {
|
||||||
|
const result = filterTransactions(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
hash: "0x" + "1".repeat(64),
|
||||||
|
symbol: claim,
|
||||||
|
contractAddress: TOKEN_CONTRACT,
|
||||||
|
holders: 900000,
|
||||||
|
valueGwei: null,
|
||||||
|
isContractCall: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ hideSpoofedSymbols: true },
|
||||||
|
);
|
||||||
|
expect(result.transactions).toEqual([]);
|
||||||
|
expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]);
|
||||||
|
|
||||||
|
send.renderSendTokenSelect({
|
||||||
|
address: HOLDER,
|
||||||
|
tokenBalances: [
|
||||||
|
{
|
||||||
|
address: TOKEN_CONTRACT,
|
||||||
|
symbol: claim,
|
||||||
|
decimals: 18,
|
||||||
|
balance: "5",
|
||||||
|
holders: 900000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
global.document.getElementById("send-token").children,
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The detail screen tells the two apart by the token contract, which only
|
||||||
|
// a token transfer has, so a token reporting the native label still reads
|
||||||
|
// as a token transfer.
|
||||||
|
test("the transaction detail screen's type line", () => {
|
||||||
|
const entry = {
|
||||||
|
hash: "0x" + "2".repeat(64),
|
||||||
|
from: RECIPIENT,
|
||||||
|
to: HOLDER,
|
||||||
|
value: "1.0000",
|
||||||
|
exactValue: "1.0",
|
||||||
|
symbol,
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
direction: "received",
|
||||||
|
directionLabel: "Received",
|
||||||
|
chainId: NETWORKS[networkId].chainId,
|
||||||
|
};
|
||||||
|
transactionDetail.show({ ...entry, contractAddress: null });
|
||||||
|
expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer");
|
||||||
|
transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT });
|
||||||
|
expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the insufficient-balance error", async () => {
|
||||||
|
await confirmSend("2");
|
||||||
|
expect(
|
||||||
|
global.document.getElementById("confirm-errors").innerHTML,
|
||||||
|
).toContain(
|
||||||
|
"You have 1.5000 " +
|
||||||
|
symbol +
|
||||||
|
" but are trying to send 2 " +
|
||||||
|
symbol +
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A transaction's value and fee are in the native currency of the network the
|
||||||
|
// transaction is on, which need not be the active one. A site can switch the
|
||||||
|
// active network after its transaction is prepared and back before it is
|
||||||
|
// signed, and a popup opened after a switch shows a sent or listed transaction
|
||||||
|
// again. The wallet's balances follow the active network; these do not.
|
||||||
|
describe.each([
|
||||||
|
["mainnet", "sepolia", "ETH"],
|
||||||
|
["sepolia", "mainnet", "SepoliaETH"],
|
||||||
|
])(
|
||||||
|
"a %s transaction shown with %s active reads %s",
|
||||||
|
(txNetworkId, activeNetworkId, symbol) => {
|
||||||
|
const chainId = NETWORKS[txNetworkId].chainId;
|
||||||
|
const hash = "0x" + "3".repeat(64);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elements.clear();
|
||||||
|
clearPrices();
|
||||||
|
state.networkId = activeNetworkId;
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
addresses: [{ address: HOLDER, balance: "1.5" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.fraudContracts = [];
|
||||||
|
state.currentView = null;
|
||||||
|
txStatus.init({ doRefreshAndRender() {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
txStatus.endWait();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the approval screen's value and fee", async () => {
|
||||||
|
await approveTx(chainId);
|
||||||
|
expect(text("approve-tx-network")).toBe(NETWORKS[txNetworkId].name);
|
||||||
|
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||||
|
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the wait, success and error screens", async () => {
|
||||||
|
const txInfo = {
|
||||||
|
from: HOLDER,
|
||||||
|
to: RECIPIENT,
|
||||||
|
amount: "0.0100",
|
||||||
|
token: "ETH",
|
||||||
|
tokenSymbol: null,
|
||||||
|
chainId,
|
||||||
|
};
|
||||||
|
txStatus.showWait(txInfo, hash);
|
||||||
|
expect(text("wait-tx-summary")).toBe("0.0100 " + symbol);
|
||||||
|
txStatus.showError(txInfo, hash, "Failed.");
|
||||||
|
expect(text("error-tx-summary")).toBe("0.0100 " + symbol);
|
||||||
|
// A later popup resumes the wait, and the receipt is there.
|
||||||
|
state.viewData = {
|
||||||
|
pendingWait: { txInfo, hash, broadcastTime: Date.now() },
|
||||||
|
};
|
||||||
|
txStatus.restoreWait();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
expect(text("success-tx-summary")).toBe("0.0100 " + symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sent from the Send screen on the transaction's network, then
|
||||||
|
// resumed by a popup that opens after the active network changed.
|
||||||
|
test("the wait screen after a send", async () => {
|
||||||
|
state.networkId = txNetworkId;
|
||||||
|
await confirmSend("0.1");
|
||||||
|
confirmTx.init({});
|
||||||
|
global.document.getElementById("confirm-tx-password").value = "pw";
|
||||||
|
await global.document
|
||||||
|
.getElementById("btn-confirm-send")
|
||||||
|
.handlers.get("click")();
|
||||||
|
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||||
|
state.networkId = activeNetworkId;
|
||||||
|
txStatus.restoreWait();
|
||||||
|
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the transaction detail screen's type line and fee", async () => {
|
||||||
|
debugFetch.mockImplementationOnce(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ fee: { value: "21000000000000" } }),
|
||||||
|
}));
|
||||||
|
transactionDetail.show({
|
||||||
|
hash,
|
||||||
|
from: RECIPIENT,
|
||||||
|
to: HOLDER,
|
||||||
|
value: "1.0000",
|
||||||
|
exactValue: "1.0",
|
||||||
|
symbol,
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
direction: "received",
|
||||||
|
directionLabel: "Received",
|
||||||
|
contractAddress: null,
|
||||||
|
chainId,
|
||||||
|
});
|
||||||
|
expect(text("tx-detail-type")).toBe(
|
||||||
|
"Native " + symbol + " Transfer",
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
global.document.getElementById("tx-detail-fee").innerHTML,
|
||||||
|
).toContain("0.000021 " + symbol);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -722,6 +722,28 @@ describe("the base profile the sweep corrupts", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Home, AddressDetail and AddressToken load their transactions inside a catch
|
||||||
|
// that only logs, so a boot that fails there still renders the view and passes
|
||||||
|
// the tests above while none of that code runs.
|
||||||
|
describe("the base profile loads transactions", () => {
|
||||||
|
for (const view of ["main", "address", "address-token"]) {
|
||||||
|
test(`on ${view} without logging a failure`, async () => {
|
||||||
|
const consoleError = jest.spyOn(console, "error");
|
||||||
|
try {
|
||||||
|
await bootPopup(restoringOnto(view));
|
||||||
|
const failures = consoleError.mock.calls
|
||||||
|
.map((args) => args.join(" "))
|
||||||
|
.filter((line) =>
|
||||||
|
/loadHomeTxs failed|loadTransactions failed/.test(line),
|
||||||
|
);
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
consoleError.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// A field the ROUTER itself reads — the two it gates on and the two
|
// A field the ROUTER itself reads — the two it gates on and the two
|
||||||
// hasValidAddress() indexes with. A hostile value in one of these legitimately
|
// hasValidAddress() indexes with. A hostile value in one of these legitimately
|
||||||
// changes which view renders, so each gets its own boot per view and is held
|
// changes which view renders, so each gets its own boot per view and is held
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// The signature prompt shows a personal message as the bytes that are signed
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
|
||||||
|
// text it decodes to with control characters, line and paragraph separators
|
||||||
|
// and characters that paint nothing marked rather than obeyed, markup shown as
|
||||||
|
// text, laid out in byte order, and a message that is not hex as plain text
|
||||||
|
// that cannot be signed.
|
||||||
|
//
|
||||||
|
// Driven against a minimal DOM stub in the shape
|
||||||
|
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
|
||||||
|
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
|
||||||
|
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { hexlify, toUtf8Bytes } = require("ethers");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const approval = require("../src/popup/views/approval");
|
||||||
|
|
||||||
|
const FROM = "0x0000000000000000000000000000000000000a11";
|
||||||
|
|
||||||
|
// Built from their code points so that this file holds none of them.
|
||||||
|
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
|
||||||
|
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
||||||
|
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
|
||||||
|
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
|
||||||
|
const HANGUL_FILLER = String.fromCodePoint(0x3164);
|
||||||
|
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
|
||||||
|
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
|
||||||
|
|
||||||
|
function makeElement(id) {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||||
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||||
|
contains: (n) => classes.has(n),
|
||||||
|
toggle: (n, force) => {
|
||||||
|
const on = force === undefined ? !classes.has(n) : force;
|
||||||
|
if (on) classes.add(n);
|
||||||
|
else classes.delete(n);
|
||||||
|
return on;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
appendChild: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument() {
|
||||||
|
const els = new Map();
|
||||||
|
return {
|
||||||
|
getElementById(id) {
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement: () => makeElement("created"),
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id) {
|
||||||
|
return globalThis.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the signature prompt for a personal_sign of `message`, the way the
|
||||||
|
// popup does: it asks the background for the approval and show() draws it.
|
||||||
|
async function openPersonalSign(message) {
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
globalThis.window = { location: { search: "" } };
|
||||||
|
globalThis.chrome.runtime = {
|
||||||
|
connect: () => ({ postMessage: () => {} }),
|
||||||
|
sendMessage: (msg, reply) => {
|
||||||
|
if (!reply) return;
|
||||||
|
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||||
|
reply({
|
||||||
|
type: "sign",
|
||||||
|
origin: "https://dapp.example",
|
||||||
|
isPhishingDomain: false,
|
||||||
|
approvedFrom: FROM,
|
||||||
|
signParams: { method: "personal_sign", message, from: FROM },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
approval.init({});
|
||||||
|
await approval.show(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message box's markup as the text a reader sees: tags dropped.
|
||||||
|
function shownMessage() {
|
||||||
|
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.wallets = [];
|
||||||
|
state.activeAddress = FROM;
|
||||||
|
state.viewData = {};
|
||||||
|
state.viewStack = [];
|
||||||
|
state.currentView = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a right-to-left override is marked, so the text reads in byte order", async () => {
|
||||||
|
// Obeyed, the override shows "0001" as "1000".
|
||||||
|
const text =
|
||||||
|
"Pay " +
|
||||||
|
RIGHT_TO_LEFT_OVERRIDE +
|
||||||
|
"0001" +
|
||||||
|
POP_DIRECTIONAL_FORMATTING +
|
||||||
|
" ETH";
|
||||||
|
await openPersonalSign(hexlify(toUtf8Bytes(text)));
|
||||||
|
const html = node("approve-sign-message").innerHTML;
|
||||||
|
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
|
||||||
|
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
|
||||||
|
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a zero-width character is marked", async () => {
|
||||||
|
await openPersonalSign(
|
||||||
|
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
|
||||||
|
);
|
||||||
|
expect(node("approve-sign-message").innerHTML).not.toContain(
|
||||||
|
ZERO_WIDTH_SPACE,
|
||||||
|
);
|
||||||
|
expect(shownMessage()).toBe("payU+200Bpal.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("variation selectors and a Hangul filler are marked", async () => {
|
||||||
|
// Each paints nothing, so a page could hide bytes after "Sign in".
|
||||||
|
await openPersonalSign(
|
||||||
|
hexlify(
|
||||||
|
toUtf8Bytes(
|
||||||
|
"Sign in" +
|
||||||
|
VARIATION_SELECTOR_1 +
|
||||||
|
VARIATION_SELECTOR_17 +
|
||||||
|
HANGUL_FILLER,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the message is laid out in byte order", async () => {
|
||||||
|
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
|
||||||
|
expect(
|
||||||
|
node("approve-sign-message").classList.contains("am-byte-order"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a control character other than a line feed is marked", async () => {
|
||||||
|
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
|
||||||
|
expect(shownMessage()).toBe("aU+0000bU+0009c");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("line and paragraph separators are marked", async () => {
|
||||||
|
// Left in the text, a paragraph separator would end the byte-order
|
||||||
|
// layout for everything after it.
|
||||||
|
await openPersonalSign(
|
||||||
|
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
|
||||||
|
);
|
||||||
|
const html = node("approve-sign-message").innerHTML;
|
||||||
|
expect(html).not.toContain(LINE_SEPARATOR);
|
||||||
|
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
|
||||||
|
expect(shownMessage()).toBe("aU+2028bU+2029");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a line feed is shown as a line break", async () => {
|
||||||
|
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
|
||||||
|
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The message box is written as HTML, so a site's markup has to arrive there
|
||||||
|
// escaped, as the text it is.
|
||||||
|
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
|
||||||
|
["a message that is not hex", MARKUP],
|
||||||
|
])("markup in %s is shown as text, not as markup", async (_, message) => {
|
||||||
|
await openPersonalSign(message);
|
||||||
|
expect(node("approve-sign-message").innerHTML).toBe(
|
||||||
|
"<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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -12,7 +12,11 @@
|
|||||||
// every log level is printed.
|
// every log level is printed.
|
||||||
|
|
||||||
const { FetchRequest } = require("ethers");
|
const { FetchRequest } = require("ethers");
|
||||||
const { getProvider, refreshBalances } = require("../src/shared/balances");
|
const {
|
||||||
|
getProvider,
|
||||||
|
lookupTokenInfo,
|
||||||
|
refreshBalances,
|
||||||
|
} = require("../src/shared/balances");
|
||||||
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
||||||
const { resolveEnsName } = require("../src/shared/ens");
|
const { resolveEnsName } = require("../src/shared/ens");
|
||||||
const { setRuntimeDebug } = require("../src/shared/log");
|
const { setRuntimeDebug } = require("../src/shared/log");
|
||||||
@@ -90,3 +94,12 @@ test("the balance refresh", async () => {
|
|||||||
expectFailureLoggedWithoutKey("ETH balance failed");
|
expectFailureLoggedWithoutKey("ETH balance failed");
|
||||||
expectFailureLoggedWithoutKey("ENS reverse 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:");
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
// 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
@@ -423,3 +423,80 @@ 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -148,6 +148,173 @@ 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.
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
|||||||
jest.doMock("qrcode", () => QRCode);
|
jest.doMock("qrcode", () => QRCode);
|
||||||
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
||||||
|
|
||||||
|
// Taken before any boot replaces them; see bootPopup().
|
||||||
|
const realSetInterval = globalThis.setInterval;
|
||||||
|
const realClearInterval = globalThis.clearInterval;
|
||||||
|
|
||||||
const POPUP_HTML = fs.readFileSync(
|
const POPUP_HTML = fs.readFileSync(
|
||||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -259,9 +263,12 @@ 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: () => [],
|
filterTransactions: () => ({ transactions: [], newFraudContracts: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const storage =
|
const storage =
|
||||||
@@ -292,9 +299,20 @@ async function bootPopup(stored, options) {
|
|||||||
}),
|
}),
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
};
|
};
|
||||||
// The 10s refresh loop init() starts would outlive the test.
|
// The ten-second refresh init() starts, and a transaction wait's timers,
|
||||||
const realSetInterval = globalThis.setInterval;
|
// would outlive the test. So every interval is recorded rather than
|
||||||
globalThis.setInterval = () => 0;
|
// started, clearInterval() removes it as a browser would, and tick() below
|
||||||
|
// 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");
|
||||||
|
|
||||||
@@ -316,8 +334,6 @@ async function bootPopup(stored, options) {
|
|||||||
}
|
}
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
globalThis.setInterval = realSetInterval;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storage,
|
storage,
|
||||||
document,
|
document,
|
||||||
@@ -337,6 +353,12 @@ 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: () => {
|
||||||
@@ -354,6 +376,8 @@ 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 = {
|
||||||
|
|||||||
+66
-13
@@ -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);
|
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
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);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
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);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
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);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
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);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1427,12 +1427,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
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);
|
||||||
@@ -1452,10 +1453,46 @@ 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, 2);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 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(
|
||||||
[],
|
[],
|
||||||
@@ -1476,7 +1513,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
|
||||||
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);
|
||||||
@@ -1515,19 +1552,31 @@ 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(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(
|
||||||
|
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(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(
|
||||||
|
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(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(
|
||||||
|
VICTIM,
|
||||||
|
BLOCKSCOUT,
|
||||||
|
"0x1",
|
||||||
|
);
|
||||||
expect(filterTransactions(txs, filters()).transactions).toEqual(
|
expect(filterTransactions(txs, filters()).transactions).toEqual(
|
||||||
txs,
|
txs,
|
||||||
);
|
);
|
||||||
@@ -1539,7 +1588,11 @@ 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(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(
|
||||||
|
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([]);
|
||||||
});
|
});
|
||||||
@@ -1555,7 +1608,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await expect(
|
await expect(
|
||||||
fetchRecentTransactions(VICTIM, BLOCKSCOUT),
|
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"),
|
||||||
).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 { parseUnits } = require("ethers");
|
const { formatUnits, parseUnits } = require("ethers");
|
||||||
const {
|
const {
|
||||||
displayedDecimals,
|
displayedDecimals,
|
||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
@@ -25,7 +25,17 @@ describe("displayedDecimals", () => {
|
|||||||
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
|
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refuses anything that is not a uint8", () => {
|
// decimals() is a uint8, but formatUnits() and parseUnits() stop at 80
|
||||||
|
// 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,6 +6,8 @@ const {
|
|||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
|
maxEthAmount,
|
||||||
|
maxTokenAmount,
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
} = require("../src/shared/txValidation");
|
} = require("../src/shared/txValidation");
|
||||||
@@ -306,6 +308,72 @@ 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.
|
||||||
|
|||||||
@@ -554,6 +554,33 @@ 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",
|
||||||
@@ -831,6 +858,104 @@ 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,6 +126,19 @@ 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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user