Compare commits

..
1 Commits
Author SHA1 Message Date
sneak e791e06fb1 fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already runs the check loadState()
runs at open; a save refused by it now raises the recovery screen, stops the
ten-second refresh, and passes the screen to showView(), which runs the leave
cleanup of the screen it replaces and records it as the current view. From
then on showView() shows nothing else, so a transaction wait or a later save
cannot take the user off it or clear an export or a typed confirmation. Any
other failed save keeps the "NOT SAVED" banner. The popup test harness now
honours clearInterval().

Model: opus-5-5
2026-10-04 20:21:04 +00:00
39 changed files with 213 additions and 1466 deletions
+5 -4
View File
@@ -22,10 +22,11 @@ on: [push]
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked while reports of the
# Chrome suite failing under load are still open; the "In CI" section of
# README.md names them. A gate that fails at random teaches people to merge
# past red.
# block. Making e2e-chrome a required check is blocked on the measured
# flake in the dApp signing wait -- two of six runs of unmutated code on a
# loaded machine -- tracked as
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
#
# 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
+49 -107
View File
@@ -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
blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. Reports of the Chrome suite
**failing under load** are still open, among them
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
failing with `the extension opened no approval window within 30000ms`, and
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), the Settings round trip
closing the popup before its network switch is saved. So a red `e2e-chrome` has
to be read before it is believed, and those failures are the blocker to ever
making this a required check. Do not answer them with a retry wrapper: a suite
That is not only a statement about configuration. The Chrome suite is
**measurably flaky under load** — two of six runs of unmutated code on a busy
machine lost the approval popup out from under the dApp signing wait, always in
the `#183` section, tracked as
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
has to be read before it is believed, and that flake is the blocker to ever
making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
@@ -696,7 +695,6 @@ src/
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
@@ -848,20 +846,6 @@ wherever shown. If a formatting rule applies in one place, it applies in every
place. Users should never see the same value rendered differently on two
screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the
active network, use the active network's. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native
ETH transfer" type line, in the contract-recipient warning or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording.
**Specific Exception — Truncation:** On some non-critical display locations, we
may truncate _a small number_ of characters from the middle of an address solely
due to display size constraints. Wherever possible, and, notably, **in all
@@ -903,9 +887,7 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
shows is a V4 exact-in `amountIn` of zero and
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
exact-in `amountIn` of zero.
shows is a V4 exact-in `amountIn` of zero.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -998,8 +980,7 @@ read:
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
not necessarily all of it; the wait, success and error screens show it with
the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
the same words. `Unlimited` and `All available (V4 open delta)` keep their
wording. When a V2 exact-out step sets `Min. received`, that line shows its
`amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
@@ -1010,22 +991,11 @@ read:
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known. 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.
read `0.0000` when the token's scale was known.
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
@@ -1038,12 +1008,6 @@ unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
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
Prices are fetched for the top 25 tokens only, so an address can hold assets the
@@ -1122,21 +1086,15 @@ balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. A token's holder count is
unknown when the explorer reports none, or reports anything other than a whole
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
or more, so such a token is shown only when it is on the bundled list or
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
admits, however small, but a holding below 0.000001 is left out of the balance
lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
every nonzero holding of a token it admits, however small, but a holding below
0.000001 is left out of the balance lists, the send-screen token selector, the
address total and the remove-address warning (`isBelowOneMillionth()` in
`src/shared/amountDisplay.js`). The Send and confirmation screens show it when
its token is the one being sent. Tracked tokens with a zero balance are listed
as well while "Show tracked tokens with zero balance" is on.
#### Stored state and its version
@@ -1445,9 +1403,7 @@ view would leave a wallet one click from deletion.
- Send / Receive buttons
- Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project
website where known. 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
website where known
- Token-filtered transaction list (only this token's transfers)
- **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static
@@ -1613,9 +1569,7 @@ view would leave a wallet one click from deletion.
- "Transaction" heading, "Back" button
- Transaction hash: full hash (tap to copy) + etherscan link
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
the token reports.
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
- Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
@@ -1789,10 +1743,7 @@ view would leave a wallet one click from deletion.
new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever
- That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back. 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.
- The wallet's name, and a text input asking for it to be typed back
- Error line
- "Delete This Wallet Forever" button
- **Transitions**:
@@ -1800,9 +1751,9 @@ view would leave a wallet one click from deletion.
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match, or the field is empty)
→ "That is not the name of this wallet. Type <name> to confirm." on
the error line, nothing deleted
- "Delete This Wallet Forever" (name does not match) → "That is not the name
of this wallet. Type <name> to confirm." on the error line, nothing
deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather
than parent and child: nothing is pushed on the way here, so both have
@@ -1811,13 +1762,8 @@ view would leave a wallet one click from deletion.
secret protects nobody: an attacker at the popup who wants the wallet gone can
uninstall the extension, so the only person such a gate stops is the owner who
forgot it. The typed name is a check that the user knows which wallet they are
on, not a secret, so it is matched as the user can see it: letter case,
surrounding spaces and repeated inner spaces are ignored, and characters that
paint nothing (format characters such as the zero-width space,
default-ignorable characters, and DELETE — the same set
`src/shared/symbolSpoof.js` strips) are removed from both sides before
comparing. An empty field, or one holding only spaces or such characters, is
refused whatever the wallet is called.
on, not a secret, so it is matched with surrounding spaces and letter case
ignored.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material.
@@ -2039,15 +1985,16 @@ view would leave a wallet one click from deletion.
`loadState()` refusing it, so the popup has no profile at all. While the popup
is open, on any screen, it is a save refusing it: every `saveState()` reads
the stored record and runs the same check before writing, so the popup finds
it at the next navigation or ten-second refresh, whether or not the network
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
fails for any other reason, such as a storage read or write that errors, gets
the "NOT SAVED" banner instead and leaves the screen as it is. The screen it
replaces is left as any navigation leaves it, so a revealed phrase or key, or
a typed password, is wiped. Once up, the screen stays until the popup closes
or reloads: work still running in the popup, such as a transaction wait,
cannot replace it, even after the record is erased in another window. It is
the only screen that never appears during ordinary use.
it at the next navigation, or at the next ten-second balance refresh that
reaches the network ([#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 the record is erased: work still running in the popup,
such as a transaction wait, cannot replace it, and nothing in AutistMask
writes over a record that fails the check, so it cannot become readable again
underneath. It is the only screen that never appears during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
view, no message, no control — while every dApp call answered a generic
internal error, and no reset or wipe control existed anywhere in the product.
@@ -2074,20 +2021,20 @@ view would leave a wallet one click from deletion.
Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this
screen is up, because every screen behind it renders from the profile that
could not be read. `showView()` is not used to raise it, for the same reason:
it reads and writes the state singleton. Under an open popup the screen is
passed to `showView()` only to run the replaced screen's cleanup; from then on
`showView()` shows nothing else in that popup.
could not be read. At open `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton. Under an open popup it is
also passed to `showView()`, which runs the replaced screen's cleanup and
records it as the current view, and `showView()` shows nothing else after
that.
- **Both controls are required.** An export with no reset leaves the user
looking at a broken profile with no way to use the wallet again; a reset with
no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
and for the same reason: there is no password to gate this with, since there
is no profile to check one against.
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
can become readable again under an open popup, erased in another window, and
the next save from that popup then succeeds. A popup opened after that opens
normally.
- Not in `RESTORABLE_VIEWS`, and not persisted as the current view: at open
nothing on this path writes state at all, and under an open popup the check
that raised the screen refuses the save that would record it.
### External Services
@@ -2396,8 +2343,7 @@ indexes it as a real token transfer.
act on and what the user believes they own rather than what the history
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
so they cannot answer the question differently. A symbol the list maps to no
contract at all — the native asset's labels: `"ETH"` and every network's
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
it.
@@ -2406,8 +2352,7 @@ indexes it as a real token transfer.
fewer than 1,000 holders are hidden from transaction history by default.
Legitimate tokens have substantial holder counts; poisoning tokens typically
have zero. This catches new poisoning contracts that use novel symbols not in
the known token list. A transfer whose token's holder count is unknown (see
Data Model) is kept: only a reported count below 1,000 hides it.
the known token list.
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
contract addresses. Token transfers involving these contracts are filtered
@@ -2417,9 +2362,7 @@ indexes it as a real token transfer.
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are
excluded from the token selector on the send screen. This prevents users from
accidentally interacting with a spoofed token that appeared in their balance
via a fake Transfer event. 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.
via a fake Transfer event.
- **Dust transaction filtering**: A second wave of the same attack used real
native ETH transfers instead of fake tokens. Transaction
@@ -2443,9 +2386,8 @@ indexes it as a real token transfer.
both cases identically to the history. The fraud contract blocklist is applied
unconditionally on that selector and is not consulted by the balance list at
all. The low-holder setting also gates the send selector, while the balance
list's own 1,000-holder floor is unconditional (see Data Model). An unknown
holder count passes the history and send-selector filters but not that floor.
The dust threshold applies to the transaction history alone.
list's own 1,000-holder floor is unconditional (see Data Model). The dust
threshold applies to the transaction history alone.
#### Phishing Domain Protection
+8 -93
View File
@@ -45,103 +45,18 @@ but the review is broader than any of them.
# Completed Steps
- 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.
Every save already ran the check the popup runs at open; a save that fails
that check now raises the recovery screen and stops the ten-second refresh, so
the popup finds the record at the next navigation or 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 can replace it, and a later
save or a transaction wait that ends does not clear an export or a typed
confirmation. Any other failed save still gets the banner and leaves the
screen alone.
- 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
+1 -7
View File
@@ -333,13 +333,7 @@ it is hidden from your transaction history and from the send token list.
from transaction history and the send token list, and are left out of your
balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. 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.
for address poisoning typically have zero.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that
+1 -5
View File
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
}
}
// Open approval in a separate popup window, unless it is no longer pending.
// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// 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
@@ -446,10 +446,6 @@ async function openApprovalWindow(id) {
);
}
// Already answered: a site-connection prompt decided before the toolbar
// popup raised for it had loaded, whose openPopup() rejects only now.
if (!pendingApprovals[id]) return;
let win = null;
try {
win = await windowsCreate(opts);
+14 -7
View File
@@ -640,13 +640,19 @@
Double-check the address before sending.
</div>
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div
id="confirm-contract-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
class="mb-2"
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
id="confirm-burn-warning"
class="mb-2"
@@ -684,13 +690,14 @@
Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount.
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div
id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
></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,
so show() in confirmTx.js sets it. -->
<div
+9 -9
View File
@@ -164,19 +164,19 @@ async function init() {
// 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.
// (https://git.eeqj.de/sneak/AutistMask/issues/373). The recovery screen
// is then also passed to showView(), which runs the leave cleanup of the
// screen it replaces, so a phrase, key or password on it is wiped, and
// records it as the current view, after which showView() shows nothing
// else. The save showView() fires fails too and 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);
showView("state-recovery");
} else {
showSaveFailureBanner(e);
}
+1 -2
View File
@@ -12,7 +12,7 @@ const {
goBack,
pushCurrentView,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -153,7 +153,6 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
currentNetwork().chainId,
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
+2 -4
View File
@@ -10,7 +10,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
balanceLine,
unknownableAmount,
renderAddressHtml,
@@ -18,7 +17,7 @@ const {
goBack,
pushCurrentView,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
@@ -107,7 +106,7 @@ function show() {
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = nativeCurrency();
symbol = "ETH";
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
@@ -227,7 +226,6 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
currentNetwork().chainId,
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
+4 -19
View File
@@ -12,10 +12,7 @@ const {
formatFee,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
formatUnits,
@@ -222,12 +219,8 @@ function showTxFee(approvedTx) {
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. In the native currency of the network shown
// above, as the value is.
$("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId),
);
// fee reads the same on both.
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
let detail =
gasLimit.toString() +
@@ -271,7 +264,6 @@ function showTxApproval(details) {
amount: formatTxValue(ethValue),
token: "ETH",
tokenSymbol: null,
chainId: approvedTx.chainId,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
@@ -337,15 +329,8 @@ function showTxApproval(details) {
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: a site can switch the
// active network after this transaction is prepared and back before it is
// signed.
$("approve-tx-value").textContent =
ethValueFormatted +
" " +
nativeCurrencyByChainId(approvedTx.chainId) +
(usdStr ? " (" + usdStr + ")" : "");
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx);
+11 -41
View File
@@ -11,14 +11,13 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
goBack,
onViewLeave,
formatFee,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
@@ -91,7 +90,7 @@ function show(txInfo) {
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const symbol = displaySymbol(rawSymbol);
// Transaction type
@@ -99,7 +98,7 @@ function show(txInfo) {
$("confirm-type").textContent =
"ERC-20 token transfer (" + symbol + ")";
} else {
$("confirm-type").textContent = "Native " + symbol + " transfer";
$("confirm-type").textContent = "Native ETH transfer";
}
// Token contract section (ERC-20 only)
@@ -163,7 +162,7 @@ function show(txInfo) {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
truncateAmountNeverZero(bal) + " ETH",
balUsd,
);
}
@@ -198,19 +197,6 @@ function show(txInfo) {
// estimate landing later never moves anything.
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
// The fee-unknown message names its cause, which is also known here.
// Without the token's scale estimateGas() cannot encode the transfer, so
@@ -259,9 +245,7 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
const { canSend, codes } = validateTransfer({
isErc20,
@@ -274,7 +258,7 @@ function renderValidation(txInfo) {
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the
// gas and fee-unknown ones, which show() sets.
// fee-unknown one, which show() sets.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
@@ -303,13 +287,9 @@ function renderValidation(txInfo) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" " +
symbol +
" but are trying to send " +
" ETH but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
" ETH.",
);
}
@@ -398,23 +378,17 @@ async function estimateGas(txInfo) {
// The fee line goes through formatFee(), as the approval screen's
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" " +
nativeCurrency() +
" reserved";
" ETH reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
);
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
@@ -534,10 +508,6 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx;
try {
const signer = getSignerForAddress(
+13 -26
View File
@@ -12,7 +12,6 @@ const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
let deleteWalletIndex = null;
let lostPasswordIndex = null;
@@ -21,31 +20,21 @@ let ctx = null;
// The name shown for a wallet, and on the lost-password screen the string
// the user has to type back. One function so the two cannot disagree: a
// confirmation that asks for a name other than the one on screen is
// unusable. 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.
// unusable.
function displayName(walletIdx) {
const wallet = state.wallets[walletIdx];
const name = wallet && wallet.name;
if (name && confirmKey(name)) return name;
return "Wallet " + (walletIdx + 1);
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
}
// What the typed confirmation and the wallet name are compared as. HTML
// collapses runs of whitespace when it renders the name, so a wallet named
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
// see the second space and cannot type a string that matches the stored
// name. Characters that paint nothing, such as a zero-width space, are
// invisible the same way and are removed first. Comparing this form on
// both sides is what keeps the confirmation satisfiable, on the one screen
// whose whole purpose is unwedging a user who is already stuck. Case and
// surrounding space go the same way.
// name. Comparing collapsed on both sides is what keeps the confirmation
// satisfiable, on the one screen whose whole purpose is unwedging a user
// who is already stuck. Case and surrounding space go the same way.
function confirmKey(name) {
return name
.replace(INVISIBLE_CHARACTERS, "")
.trim()
.replace(/\s+/g, " ")
.toLowerCase();
return name.trim().replace(/\s+/g, " ").toLowerCase();
}
// Drop the password from the DOM and the wallet selection from the
@@ -185,16 +174,14 @@ function init(_ctx) {
return;
}
// Case, surrounding spaces, repeated inner spaces and invisible
// characters are not part of the confirmation; see confirmKey().
// This asks whether the user knows which wallet they are on; it is
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
// teach the user to distrust the control. An empty field is
// refused whatever the wallet is called, so no stored name can
// ever be confirmed by typing nothing.
const typed = confirmKey($("delete-wallet-lost-name-input").value);
// Case, surrounding spaces and repeated inner spaces are not part
// of the confirmation; see confirmKey(). This asks whether the
// user knows which wallet they are on; it is not a secret, and
// refusing "wallet 2" for "Wallet 2" would only teach the user to
// distrust the control.
const typed = $("delete-wallet-lost-name-input").value;
const expected = displayName(lostPasswordIndex);
if (typed === "" || typed !== confirmKey(expected)) {
if (confirmKey(typed) !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " +
expected +
+11 -36
View File
@@ -52,8 +52,10 @@ const VIEWS = [
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read, never by showView() (see there), but listed so that
// every view-hiding loop covers it.
// cannot be read. At open it is not reached through showView(), since the
// state singleton refuses to be read; under an open popup,
// src/popup/index.js also passes it to showView() so the screen it
// replaces is left like any other.
"state-recovery",
];
@@ -84,28 +86,17 @@ function hideError(id) {
el.style.visibility = "hidden";
}
// Set when src/popup/index.js passes the recovery screen to showView(), and
// never cleared. Kept in memory for this popup's life, never in
// state.currentView, which is saved: a popup opened later must not inherit it.
let stateRecoveryShown = false;
function showView(name) {
// The recovery screen, once up, is never replaced: work still running
// when it went up, such as a transaction wait, must not take the user off
// it or clear what they exported or typed there
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
if (stateRecoveryShown) return;
if (state.currentView === "state-recovery") return;
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
// Passed here only so the screen it replaces is left like any other;
// stateRecovery.show() raises it, and it is never the current view.
if (name === "state-recovery") {
stateRecoveryShown = true;
return;
}
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
@@ -267,31 +258,16 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null;
}
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// the Send and confirmation screens, which send on the active network, label a
// native amount with this; a transaction already made or requested is labelled
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
// id, not its label, and stays "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when
// the ETH price is known. The USD value is of the exact fee, not of the
// truncated figure.
function formatFee(wei, symbol) {
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
// figure.
function formatFee(wei) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) +
" " +
symbol +
(usd ? " (" + usd + ")" : "")
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
);
}
@@ -333,7 +309,7 @@ function balanceLine(symbol, amount, price, tokenId) {
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
"ETH",
parseFloat(addr.balance || "0"),
getPrice("ETH"),
"ETH",
@@ -690,7 +666,6 @@ module.exports = {
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
nativeCurrency,
formatFee,
addressColor,
addressDotHtml,
+3 -13
View File
@@ -10,17 +10,11 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
} = require("./helpers");
const {
state,
saveState,
currentAddress,
currentNetwork,
} = require("../../shared/state");
const { state, saveState, currentAddress } = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const {
updateSendBalance,
@@ -74,7 +68,7 @@ function renderTotalValue() {
return;
}
const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethStr = ethBal.toFixed(4) + " ETH";
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd;
@@ -188,11 +182,7 @@ async function loadHomeTxs(ctx) {
try {
const fetches = allAddresses.map((addr) =>
fetchRecentTransactions(
addr,
state.blockscoutUrl,
currentNetwork().chainId,
),
fetchRecentTransactions(addr, state.blockscoutUrl),
);
const results = await Promise.all(fetches);
+2 -5
View File
@@ -5,8 +5,6 @@ const {
showFlash,
addressTitle,
displaySymbol,
escapeHtml,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -126,7 +124,7 @@ function updateToValidation() {
function renderSendTokenSelect(addr) {
const sel = $("send-token");
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
sel.innerHTML = '<option value="ETH">ETH</option>';
const fraudSet = new Set(
(state.fraudContracts || []).map((a) => a.toLowerCase()),
);
@@ -206,8 +204,7 @@ function updateSendBalance() {
$("send-balance").textContent =
"Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" " +
nativeCurrency();
" ETH";
} else {
const symbol = resolveSymbol(
token,
+2 -1
View File
@@ -6,7 +6,8 @@
// the time this runs, the stored record has been REFUSED, deliberately: at
// open loadState() refused it and reading the singleton throws
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
// a save refused it, so every further save fails too
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
//
// So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it
+7 -15
View File
@@ -21,7 +21,6 @@ const {
goBack,
} = require("./helpers");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
@@ -42,10 +41,8 @@ function getTransactionType(tx) {
return "Token Approval";
return "Contract Call";
}
// By the token contract, not the symbol: a token chooses its own symbol
// and can report the native token's, but only a token transfer has one.
if (tx.contractAddress) return "ERC-20 Token Transfer";
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
return "Native ETH Transfer";
}
function blockieHtml(address) {
@@ -87,11 +84,6 @@ function show(tx) {
isContractCall: tx.isContractCall || false,
method: tx.method || null,
contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// a site can switch the active network before a later popup
// shows this screen again.
chainId: tx.chainId,
},
};
render();
@@ -187,7 +179,7 @@ function render() {
if (el) el.classList.add("hidden");
}
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
loadFullTxDetails(tx.hash, tx.to);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
@@ -205,7 +197,7 @@ function showDetailField(sectionId, contentId, value) {
section.classList.remove("hidden");
}
function populateOnChainDetails(txData, chainId) {
function populateOnChainDetails(txData) {
// Block number
if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number));
@@ -235,7 +227,7 @@ function populateOnChainDetails(txData, chainId) {
showDetailField(
"tx-detail-fee-section",
"tx-detail-fee",
feeEth + " " + nativeCurrencyByChainId(chainId),
feeEth + " ETH",
);
}
@@ -295,7 +287,7 @@ function populateOnChainDetails(txData, chainId) {
}
}
async function loadFullTxDetails(txHash, toAddress, chainId) {
async function loadFullTxDetails(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -312,7 +304,7 @@ async function loadFullTxDetails(txHash, toAddress, chainId) {
const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId);
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
+6 -12
View File
@@ -16,7 +16,6 @@ const {
} = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -87,13 +86,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait();
const id = waitId;
// A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: a site can switch the active network
// while this screen is open or before a later popup resumes it.
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
@@ -198,10 +193,9 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -229,7 +223,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = {
amount: txInfo.amount,
@@ -326,7 +320,7 @@ function showError(txInfo, txHash, message) {
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = {
amount: txInfo.amount,
+2 -13
View File
@@ -54,11 +54,9 @@ const {
formatEther,
getAddress,
getBytes,
toQuantity,
verifyMessage,
verifyTypedData,
} = require("ethers");
const { nativeCurrencyByChainId } = require("./networks");
// The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
@@ -409,21 +407,12 @@ function assertWithinCeilings(tx) {
price = normalizeQuantity(tx.gasPrice, "gas price");
}
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
// The fee is paid in the native currency of the network the
// transaction is for. Every caller's transaction names it.
const nativeCurrency = nativeCurrencyByChainId(
present(tx.chainId) ? toQuantity(tx.chainId) : null,
);
throw refuse(
"This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) +
" " +
nativeCurrency +
", which is more than the " +
" ETH, which is more than the " +
formatEther(MAX_TOTAL_FEE) +
" " +
nativeCurrency +
" this wallet will sign for.",
" ETH this wallet will sign for.",
);
}
}
+8 -8
View File
@@ -147,20 +147,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
const bal = scale === null ? null : formatTokenBalance(raw, scale);
// null means the explorer reported no readable count, which is
// not the same as a count of zero. This gate is not the
// low-holder display filter: it has no user-facing off switch and
// governs the whole balance list, so it stays strict and admits a
// token only on a reported count — an unreported one is no
// evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A
// legitimate token still reaches the list through the known
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
// the whole balance list, so it stays strict and admits a token
// only on a reported count — an unreported one is no evidence.
// A legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
const hasEnoughHolders =
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
+6 -15
View File
@@ -9,22 +9,13 @@
const LOW_HOLDER_THRESHOLD = 1000;
// Parse an explorer-supplied holders_count into a number, or null when it is
// not one. Only a whole number of zero or more, or a string made of nothing
// but the digits 0-9, is a count. Anything else is null, never read in part:
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
// null too: a number cannot hold it exactly, so it would come back rounded,
// or as Infinity.
// Parse an explorer-supplied holders_count into a number, or null when the
// explorer did not report one. Anything unparseable is unknown too: a count
// we cannot read is not a count of zero.
function parseHoldersCount(raw) {
if (typeof raw === "number") {
return Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
}
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
const count = Number(raw);
return Number.isSafeInteger(count) ? count : null;
}
return null;
if (raw === null || raw === undefined || raw === "") return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
// True only for a token the explorer reported as having fewer holders than
-10
View File
@@ -76,15 +76,6 @@ function networkByChainId(chainId) {
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.
// type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) {
@@ -98,6 +89,5 @@ module.exports = {
isKnownNetworkId,
networkById,
networkByChainId,
nativeCurrencyByChainId,
explorerLink,
};
+5 -12
View File
@@ -122,9 +122,9 @@ function currentNetwork() {
return networkById(state.networkId);
}
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began. saveState() diffs
// the live state against this to find only the fields THIS page changed since.
// The persisted fields as they stood at the end of this page's last
// loadState() or saveState(). saveState() diffs the live state against this
// to find only the fields THIS page actually changed.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -464,10 +464,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
// 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 current = snapshotPersisted();
const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this,
@@ -524,11 +521,7 @@ async function saveStateOnce() {
// exactly as it stood; see the note above.
rawState.hasWallet = rawState.wallets.length > 0;
// 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;
baseline = structuredClone(snapshotPersisted());
}
// showView() calls saveState() on every navigation without awaiting it, so
+2 -2
View File
@@ -11,8 +11,8 @@
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// that may bear it, or to null. Null means the symbol belongs to the native
// asset, which has no contract at all, so no contract may bear it and every
// one that does is a spoof. "ETH" is one such entry, and every network's
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
// one that does is a spoof. "ETH" is the only such entry today; the rule is
// written so that a second one needs no change here or at any call site.
//
// 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
+1 -7
View File
@@ -6,7 +6,6 @@
// 511 tokens.
const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
@@ -3611,9 +3610,7 @@ for (const t of TOKENS) {
// Build a map of symbol (uppercased) -> the set of contract addresses
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol. 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.
// bear its symbol.
//
// 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
@@ -3627,9 +3624,6 @@ for (const t of TOKENS) {
// loosen the rule, because a contract outside the set is still a spoof.
const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) {
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
}
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) {
+9 -23
View File
@@ -11,7 +11,6 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
// The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one.
@@ -29,7 +28,7 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function parseTx(tx, addrLower, chainId) {
function parseTx(tx, addrLower) {
const from = tx.from?.hash || "";
const to = tx.to?.hash || "";
const rawWei = tx.value || "0";
@@ -37,7 +36,7 @@ function parseTx(tx, addrLower, chainId) {
const method = tx.method || null;
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = nativeCurrencyByChainId(chainId);
let symbol = "ETH";
let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
@@ -91,11 +90,10 @@ function parseTx(tx, addrLower, chainId) {
holders: null,
isContractCall: toIsContract,
method: method,
chainId: chainId,
};
}
function parseTokenTransfer(tt, addrLower, chainId) {
function parseTokenTransfer(tt, addrLower) {
const from = tt.from?.hash || "";
const to = tt.to?.hash || "";
// The explorer's own answer, or null. Never a default: a transfer of
@@ -137,12 +135,10 @@ function parseTokenTransfer(tt, addrLower, chainId) {
contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "",
),
// null when the explorer reported no readable count: unknown, not
// zero. The low-holder filter declines to judge a null, so a
// legitimate token is not hidden because a field went missing
// upstream.
// null when the explorer reported no count: unknown, not zero. The
// low-holder filter declines to judge a null, so a legitimate token
// is not hidden because a field went missing upstream.
holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId,
};
}
@@ -225,15 +221,7 @@ function mergeTransactions(txs, tokenTransfers) {
return merged;
}
// `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,
) {
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address);
@@ -266,10 +254,8 @@ async function fetchRecentTransactions(
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
(ttJson.items || []).map((tt) =>
parseTokenTransfer(tt, addrLower, chainId),
),
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
const result = txs.slice(0, count);
+15 -47
View File
@@ -84,31 +84,17 @@ function present(value) {
//
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
// so a zero `amountIn` there is a literal zero and is displayed as one.
const OPEN_DELTA = Symbol("v4-open-delta");
// The Universal Router's V2 exact-in spells "the pair already holds the input
// tokens" as an amount of zero: universal-router
// `contracts/libraries/Constants.sol` declares
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
// when a v2 pair has already received input tokens"), and
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
// equals it. The swap then spends whatever an earlier step sent to the pair.
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say
// different things.
// The two amount lines that state a fact instead of a quantity. Same register
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// as a number — and deliberately not a third phrasing of "not named": these
// say different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -199,7 +185,6 @@ function decodeBalanceCheck(input) {
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
function decodeV2SwapExactIn(input) {
try {
const d = coder.decode(
@@ -207,7 +192,7 @@ function decodeV2SwapExactIn(input) {
input,
);
return {
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountIn: d[1],
amountOutMin: d[2],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
@@ -517,13 +502,7 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
// 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 (b) setOutput(b.token, b.minBalance);
}
if (cmdId === 0x00) {
@@ -644,20 +623,14 @@ function decode(data, toAddress, sources) {
}
if (present(inputAmount)) {
// Three amounts need no scale to describe and are named rather
// than formatted: V4's open delta and V2's already-paid zero,
// neither of which is a quantity at all (see OPEN_DELTA and
// ALREADY_PAID), and an unbounded permit. Those two tests come
// first — the sentinels are not bigints and cannot be compared
// with one.
// Two amounts need no scale to describe and are named rather than
// formatted: V4's open delta, which is not a quantity at all (see
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
// first — the sentinel is not a bigint and cannot be compared with
// one.
let amount;
if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount === ALREADY_PAID) {
amount = {
raw: ALREADY_PAID_AMOUNT,
display: ALREADY_PAID_AMOUNT,
};
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) {
@@ -724,15 +697,10 @@ function decode(data, toAddress, sources) {
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
// later deadline, such as the uint256 maximum, makes an invalid date,
// and toISOString() throws on one, so that deadline is said in words.
const deadlineDate = new Date(Number(deadline) * 1000);
details.push({
label: "Deadline",
value: isNaN(deadlineDate.getTime())
? "After 275760-09-13 00:00:00 (no deadline in practice)"
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
});
return {
-18
View File
@@ -599,24 +599,6 @@ describe("verifySignedTx field comparison", () => {
expect(e.message).toContain("1.0 ETH");
}
});
// The fee is in the native currency of the network the transaction is
// for, whether its chain id is the hex string the background prepares
// or the number ethers parses from a signed transaction.
test.each([
["0x1", "ETH"],
[1n, "ETH"],
["0xaa36a7", "SepoliaETH"],
[11155111n, "SepoliaETH"],
])("the refusal on chain %p names %s", (chainId, nativeCurrency) => {
expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow(
"up to 3000.0 " +
nativeCurrency +
", which is more than the 1.0 " +
nativeCurrency +
" this wallet",
);
});
});
test("every field mismatch is a refusal, not a warning", async () => {
-21
View File
@@ -2235,27 +2235,6 @@ describe("a site connection decided as the popup closes", () => {
});
});
// The prompt is decided before the toolbar popup raised for it has
// loaded; that popup is torn down and openPopup() rejects only after.
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
const bg = loadBackground({ actionPopup: true });
const opening = deferred();
bg.openPopup.mockImplementation(() => opening.promise);
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
opening.reject(new Error("the toolbar popup closed before it loaded"));
await settle();
expect(bg.created).toHaveLength(0);
});
// The port carries a decision now, so it carries the sender check the
// 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.
-69
View File
@@ -46,9 +46,6 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// U+200B, built from its code point so that it can be seen in this file.
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
@@ -345,72 +342,6 @@ describe("the typed confirmation", () => {
"secret-three",
]);
});
// A name of only spaces compares as nothing, and so does an empty
// field. Typing nothing must still delete nothing.
test.each(["", " "])(
"typing %j deletes nothing when the name is only spaces",
async (typedValue) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = " ";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = typedValue;
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets).toHaveLength(3);
expect(await persistedWallets(storage)).toHaveLength(3);
},
);
// A name that shows nothing would leave nothing on screen to type
// back, so the screen names the wallet by its position instead, and
// that is what the user types.
test.each([
["spaces", " "],
["a zero-width space", ZERO_WIDTH_SPACE],
])(
"a name of only %s is shown and typed back as Wallet 2",
async (_label, storedName) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = storedName;
await openLostPassword(deleteWallet, 1);
expect(node("delete-wallet-lost-name").textContent).toBe(
"Wallet 2",
);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
},
);
// A zero-width space paints nothing, so "My", a zero-width space and
// "Wallet" reads as "MyWallet", and that is all the user can type. HTML
// does not collapse it the way it collapses spaces, so it has to be
// removed explicitly.
test("a zero-width space inside the name is not part of it", async () => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = "My" + ZERO_WIDTH_SPACE + "Wallet";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "MyWallet";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
});
});
describe("deleting without the password", () => {
+2 -4
View File
@@ -2738,7 +2738,7 @@ async function closeApprovalPages(ctx) {
// #btn-reject on the site prompt — NOT self-proving. A page that went away
// without the click landing disconnects the approval port, the background
// settles that as 4001, and 4001 is exactly what assertUserRejection
// accepts. Both call sites arm the click trace below and assert it.
// accepts. That call site arms the click trace below and asserts it.
//
// A button that is missing or unclickable raises a different error, which is
// rethrown.
@@ -3124,9 +3124,7 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
// Not remembered: a remembered decision for this origin would
// outlive the test.
await popup.uncheck("#approve-remember");
await armClickTrace(env, popup, "#btn-reject");
await clickAndClose(popup, "#btn-reject");
await assertClickLanded(env, "#btn-reject");
await popup.click("#btn-reject");
await assertUserRejection(
phishingDapp,
-33
View File
@@ -57,39 +57,6 @@ describe("parseHoldersCount", () => {
expect(parseHoldersCount("many")).toBeNull();
expect(parseHoldersCount(NaN)).toBeNull();
});
// Each of these starts with a digit, so reading only the leading digits
// would turn it into a small reported count, and a small count is
// exactly what hides a token as spam (issue #251).
test.each(["1,000", "0x10", "1e3", "12 holders"])(
"%p is not read in part: it is unknown",
(raw) => {
expect(parseHoldersCount(raw)).toBeNull();
},
);
test("a negative count is unknown", () => {
expect(parseHoldersCount("-5")).toBeNull();
expect(parseHoldersCount(-5)).toBeNull();
});
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
// string of digits would come back rounded, and a long enough one as
// Infinity, which would pass every holder-count floor.
test("a count too large for a number to hold exactly is unknown", () => {
expect(parseHoldersCount("9007199254740993")).toBeNull();
expect(parseHoldersCount("9".repeat(400))).toBeNull();
expect(parseHoldersCount(2 ** 53)).toBeNull();
});
test("the largest count a number holds exactly still parses", () => {
expect(parseHoldersCount("9007199254740991")).toBe(
Number.MAX_SAFE_INTEGER,
);
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
Number.MAX_SAFE_INTEGER,
);
});
});
describe("isLowHolderCount", () => {
-461
View File
@@ -1,461 +0,0 @@
// The native token's label on the screens that show a native amount.
//
// src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet
// and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a
// hardcoded "ETH", so on Sepolia the balance, the value and the fee all read
// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted
// on both networks, through the real Send, confirmation and approval screens,
// with only the node and the DOM stubbed. So is that a token cannot pass for
// the native token by reporting its label, and that a transaction's figures
// carry its own network's label when another network is active.
"use strict";
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async lookupAddress() {
return null;
}
// 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getTransactionReceipt() {
return { blockNumber: 21000000 };
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// Signing a send succeeds without a key, and sending answers with a hash.
jest.mock("../src/shared/vault", () => ({
...jest.requireActual("../src/shared/vault"),
decryptWithPassword: async () => "secret",
}));
jest.mock("../src/shared/wallet", () => ({
...jest.requireActual("../src/shared/wallet"),
getSignerForAddress: () => ({
connect: () => ({
populateTransaction: async (request) => request,
sendTransaction: async () => ({ hash: "0x" + "3".repeat(64) }),
}),
}),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The approval the background hands the approval screen. Set per test.
let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub(),
runtime: {
connect: () => ({
postMessage() {},
disconnect() {},
onDisconnect: { addListener() {} },
}),
sendMessage(message, callback) {
callback(
message.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: undefined,
);
},
},
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// Views reach for .parentElement to hide whole sections.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
const txStatus = require("../src/popup/views/txStatus");
const { balanceLinesForAddress } = require("../src/popup/views/helpers");
const { filterTransactions } = require("../src/shared/transactions");
const { debugFetch } = require("../src/shared/log");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token contract that is not in the bundled token list.
const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
function text(id) {
return global.document.getElementById(id).textContent;
}
// Press Review on the Send screen for a native send of `amount`, and show the
// confirmation screen it leads to with its fee estimate settled.
async function confirmSend(amount) {
let txInfo = null;
send.init({ showConfirmTx: (info) => (txInfo = info) });
state.selectedToken = "ETH";
global.document.getElementById("send-to").value = RECIPIENT;
global.document.getElementById("send-amount").value = amount;
await global.document
.getElementById("btn-send-review")
.handlers.get("click")();
confirmTx.show(txInfo);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The approval screen for a dApp transaction sending 0.01 of the native token
// with 21000 gas at up to 20 gwei, on the network with `chainId`.
async function approveTx(chainId) {
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
approvedFrom: HOLDER,
approvedTx: {
to: RECIPIENT,
value: "10000000000000000",
data: "0x",
chainId,
gasLimit: "21000",
maxFeePerGas: "20000000000",
nonce: 0,
},
};
await approval.show("1");
}
describe.each([
["mainnet", "ETH"],
["sepolia", "SepoliaETH"],
])("on %s the native token reads %s", (networkId, symbol) => {
beforeEach(() => {
elements.clear();
clearPrices();
state.networkId = networkId;
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: HOLDER, balance: "1.5" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
});
test("the balance", async () => {
const addr = state.wallets[0].addresses[0];
expect(balanceLinesForAddress(addr, [], false)).toContain(
`<span>${symbol}</span><span>1.5000</span>`,
);
state.selectedToken = "ETH";
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol);
await confirmSend("0.1");
expect(text("confirm-balance")).toBe("1.5000 " + symbol);
});
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
test("the fee", async () => {
await confirmSend("0.1");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol);
expect(text("confirm-fee-reserve")).toBe(
"up to 0.0004 " + symbol + " reserved",
);
expect(text("confirm-gas-error")).toContain(
"You do not have enough " + symbol + " to pay the network fee",
);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
});
test("the contract-recipient warning", async () => {
await confirmSend("0.1");
expect(text("confirm-contract-warning")).toContain(
"Sending " + symbol + " or tokens directly to a contract",
);
});
// A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was.
test.each(["ETH", symbol])(
"a token claiming %s is dropped from the history and the Send selector",
(claim) => {
const result = filterTransactions(
[
{
hash: "0x" + "1".repeat(64),
symbol: claim,
contractAddress: TOKEN_CONTRACT,
holders: 900000,
valueGwei: null,
isContractCall: false,
},
],
{ hideSpoofedSymbols: true },
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]);
send.renderSendTokenSelect({
address: HOLDER,
tokenBalances: [
{
address: TOKEN_CONTRACT,
symbol: claim,
decimals: 18,
balance: "5",
holders: 900000,
},
],
});
expect(
global.document.getElementById("send-token").children,
).toEqual([]);
},
);
// The detail screen tells the two apart by the token contract, which only
// a token transfer has, so a token reporting the native label still reads
// as a token transfer.
test("the transaction detail screen's type line", () => {
const entry = {
hash: "0x" + "2".repeat(64),
from: RECIPIENT,
to: HOLDER,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "received",
directionLabel: "Received",
chainId: NETWORKS[networkId].chainId,
};
transactionDetail.show({ ...entry, contractAddress: null });
expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer");
transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT });
expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer");
});
test("the insufficient-balance error", async () => {
await confirmSend("2");
expect(
global.document.getElementById("confirm-errors").innerHTML,
).toContain(
"You have 1.5000 " +
symbol +
" but are trying to send 2 " +
symbol +
".",
);
});
});
// A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. A site can switch the
// active network after its transaction is prepared and back before it is
// signed, and a popup opened after a switch shows a sent or listed transaction
// again. The wallet's balances follow the active network; these do not.
describe.each([
["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"],
])(
"a %s transaction shown with %s active reads %s",
(txNetworkId, activeNetworkId, symbol) => {
const chainId = NETWORKS[txNetworkId].chainId;
const hash = "0x" + "3".repeat(64);
beforeEach(() => {
elements.clear();
clearPrices();
state.networkId = activeNetworkId;
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: HOLDER, balance: "1.5" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender() {} });
});
afterEach(() => {
txStatus.endWait();
});
test("the approval screen's value and fee", async () => {
await approveTx(chainId);
expect(text("approve-tx-network")).toBe(NETWORKS[txNetworkId].name);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
});
test("the wait, success and error screens", async () => {
const txInfo = {
from: HOLDER,
to: RECIPIENT,
amount: "0.0100",
token: "ETH",
tokenSymbol: null,
chainId,
};
txStatus.showWait(txInfo, hash);
expect(text("wait-tx-summary")).toBe("0.0100 " + symbol);
txStatus.showError(txInfo, hash, "Failed.");
expect(text("error-tx-summary")).toBe("0.0100 " + symbol);
// A later popup resumes the wait, and the receipt is there.
state.viewData = {
pendingWait: { txInfo, hash, broadcastTime: Date.now() },
};
txStatus.restoreWait();
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
expect(text("success-tx-summary")).toBe("0.0100 " + symbol);
});
// Sent from the Send screen on the transaction's network, then
// resumed by a popup that opens after the active network changed.
test("the wait screen after a send", async () => {
state.networkId = txNetworkId;
await confirmSend("0.1");
confirmTx.init({});
global.document.getElementById("confirm-tx-password").value = "pw";
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
debugFetch.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: async () => ({ fee: { value: "21000000000000" } }),
}));
transactionDetail.show({
hash,
from: RECIPIENT,
to: HOLDER,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "received",
directionLabel: "Received",
contractAddress: null,
chainId,
});
expect(text("tx-detail-type")).toBe(
"Native " + symbol + " Transfer",
);
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
expect(
global.document.getElementById("tx-detail-fee").innerHTML,
).toContain("0.000021 " + symbol);
});
},
);
-22
View File
@@ -722,28 +722,6 @@ describe("the base profile the sweep corrupts", () => {
}
});
// Home, AddressDetail and AddressToken load their transactions inside a catch
// that only logs, so a boot that fails there still renders the view and passes
// the tests above while none of that code runs.
describe("the base profile loads transactions", () => {
for (const view of ["main", "address", "address-token"]) {
test(`on ${view} without logging a failure`, async () => {
const consoleError = jest.spyOn(console, "error");
try {
await bootPopup(restoringOnto(view));
const failures = consoleError.mock.calls
.map((args) => args.join(" "))
.filter((line) =>
/loadHomeTxs failed|loadTransactions failed/.test(line),
);
expect(failures).toEqual([]);
} finally {
consoleError.mockRestore();
}
});
}
});
// A field the ROUTER itself reads — the two it gates on and the two
// 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
-77
View File
@@ -423,80 +423,3 @@ describe("two wallets independently created with a colliding identity", () => {
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",
]);
});
});
-23
View File
@@ -195,29 +195,6 @@ describe("a popup already open when the stored profile becomes unreadable", () =
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({
+1 -4
View File
@@ -263,12 +263,9 @@ async function bootPopup(stored, options) {
getProvider: () => ({}),
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", () => ({
fetchRecentTransactions: jest.fn(async () => []),
filterTransactions: () => ({ transactions: [], newFraudContracts: [] }),
filterTransactions: () => [],
}));
const storage =
+13 -66
View File
@@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain(
@@ -1283,7 +1283,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
const merged = txs[0];
// The received leg (the swap output) supplies the display amount.
@@ -1320,7 +1320,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000");
@@ -1346,7 +1346,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
});
respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0");
@@ -1386,7 +1386,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
@@ -1427,13 +1427,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0");
expect(txs[0].direction).toBe("sent");
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.
const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1);
@@ -1453,46 +1452,10 @@ describe("fetchRecentTransactions merge and dedup", () => {
});
respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 2);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
});
// 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 () => {
respondWith(
[],
@@ -1513,7 +1476,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0);
@@ -1552,31 +1515,19 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("an omitted holders_count parses to null", async () => {
respondWith([], spamTransferWithToken(OMITTED));
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs[0].holders).toBeNull();
});
test("a null holders_count parses to null", async () => {
respondWith([], spamTransferWithToken(NULLED));
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs[0].holders).toBeNull();
});
test("the transfer survives the low-holder filter", async () => {
respondWith([], spamTransferWithToken(OMITTED));
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(filterTransactions(txs, filters()).transactions).toEqual(
txs,
);
@@ -1588,11 +1539,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
// 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 () => {
respondWith([], spamTransferWithToken(ZERO));
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs[0].holders).toBe(0);
expect(filterTransactions(txs, filters()).transactions).toEqual([]);
});
@@ -1608,7 +1555,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
},
}));
await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"),
fetchRecentTransactions(VICTIM, BLOCKSCOUT),
).resolves.toEqual([]);
});
-125
View File
@@ -554,33 +554,6 @@ describe("uniswap decoder", () => {
);
});
test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
});
// A JavaScript date cannot hold this deadline. It used to make the whole
// swap undecoded.
test("a deadline of the uint256 maximum is stated in words", () => {
const data = buildExecute(
"0x08", // V2_SWAP_EXACT_IN
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
],
2n ** 256n - 1n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
expect(detail(result, "Deadline").value).toBe(
"After 275760-09-13 00:00:00 (no deadline in practice)",
);
});
test("formats permit amount when not unlimited", () => {
const data = buildExecute(
"0x0a",
@@ -858,104 +831,6 @@ describe("uniswap decoder", () => {
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
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
// output currency, so it says so and titles itself "Uniswap Swap" rather