Compare commits
1 Commits
414de0419b
...
125566d256
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125566d256 |
153
README.md
153
README.md
@@ -123,20 +123,18 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
|
||||
`playwright-core`, whose version must stay matched to the container's Playwright
|
||||
version — the browsers ship inside the image.
|
||||
|
||||
It covers popup load, WebAssembly compilation under the shipped CSP (see
|
||||
[Content Security Policy](#content-security-policy)), wallet creation through
|
||||
the UI, the Add Token screen, the transaction detail screen for an ERC-20
|
||||
transfer, and the recovery phrase screen — which wallet types are offered it,
|
||||
that it holds nothing before the password is accepted, that a wrong password
|
||||
reveals nothing, that leaving it by either route wipes it — including a leave
|
||||
taken while the decrypt is still running — and that reopening the popup does not
|
||||
land on it. It also covers address removal: which wallets offer the control at
|
||||
all, that the confirmation states the route back rather than showing an empty
|
||||
paragraph, that leaving the confirmation removes nothing, and that confirming it
|
||||
does. All outbound network is intercepted at the browser level and served from
|
||||
fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
||||
offline; unrecognised outbound requests are reported as failures rather than
|
||||
silently allowed.
|
||||
It covers popup load, wallet creation through the UI, the Add Token screen, the
|
||||
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
|
||||
— which wallet types are offered it, that it holds nothing before the password
|
||||
is accepted, that a wrong password reveals nothing, that leaving it by either
|
||||
route wipes it — including a leave taken while the decrypt is still running —
|
||||
and that reopening the popup does not land on it. It also covers address
|
||||
removal: which wallets offer the control at all, that leaving the confirmation
|
||||
removes nothing, and that confirming it does. All outbound network is
|
||||
intercepted at the browser level and served from fixtures in
|
||||
`tests/e2e/network.js`, so the run is deterministic and fully offline;
|
||||
unrecognised outbound requests are reported as failures rather than silently
|
||||
allowed.
|
||||
|
||||
That reporting has one bound worth knowing. Observation ends when the browser
|
||||
context is torn down, and nothing can watch traffic after that, so the run keeps
|
||||
@@ -447,9 +445,9 @@ The core hierarchy is **Wallets → Addresses**:
|
||||
Which tokens an address shows is decided by `fetchTokenBalances()` in
|
||||
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
|
||||
tokens do appear without the user adding them. An ERC-20 is shown when its
|
||||
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. That filter is
|
||||
balance is nonzero and it is in the bundled top-250 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. 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.
|
||||
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
|
||||
@@ -658,26 +656,16 @@ of it.
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses)
|
||||
- Your balance: value + symbol (USD in parentheses)
|
||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||
fetched async. The first line is what the transfer is expected to cost,
|
||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||
`gasLimit * maxFeePerGas` reserve the node requires, which is what the
|
||||
balance check gates on. The second line is omitted on a network with no
|
||||
type-2 pricing, where the two are the same number, but its space is
|
||||
reserved either way
|
||||
- Estimated network fee: "Estimating..." then the ETH amount (USD in
|
||||
parentheses) or "Unable to estimate", fetched async
|
||||
- Warnings: inline warnings from the local checks (scam address, self-send)
|
||||
plus four reserved warning boxes made visible by the async checks —
|
||||
recipient with no transaction history, recipient is a contract, burn
|
||||
address, and an Etherscan phishing/scam label
|
||||
- Errors (insufficient balance), plus three reserved error boxes — the
|
||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
||||
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
||||
estimated. The first two are mutually exclusive per transfer type, so only
|
||||
the applicable one holds space
|
||||
- Errors (insufficient balance)
|
||||
- Password: an inline field on this screen, not a modal, with its own error
|
||||
line
|
||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||
estimate is pending or unavailable)
|
||||
- "Sign & Send" button (disabled if errors)
|
||||
- **Transitions**:
|
||||
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
|
||||
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
|
||||
@@ -896,25 +884,10 @@ of it.
|
||||
- The full address (color dot, etherscan link, tap to copy), with the ENS
|
||||
name above it if resolved
|
||||
- Explanation that this only stops the wallet tracking the address: nothing
|
||||
is destroyed, no key is deleted, and funds stay where they are
|
||||
- The route back, stated with its limit, because the obvious two are both
|
||||
refused: "+" derives the next unused index (`nextIndex` is a high-water
|
||||
mark), and re-importing the wallet's key material is rejected as a
|
||||
duplicate by `findWalletByXpub` while the wallet is still present. What
|
||||
works is deleting the whole wallet in Settings — password-gated, and it
|
||||
destroys the stored secret — then importing again, whereupon
|
||||
`scanForAddresses()` rediscovers the address **only if it has on-chain
|
||||
activity**. An address that was never used does not come back. The text is
|
||||
written by `recoveryPathText()` rather than sitting in `index.html`, so it
|
||||
can name the wallet's own kind of key material: an xprv wallet has no
|
||||
recovery phrase to re-import.
|
||||
- A warning when the address holds anything, ETH or any tracked ERC-20,
|
||||
followed by the holdings themselves via `balanceLinesForAddress()` and the
|
||||
USD total via `getAddressValueUsd()`. The sentence names no figure of its
|
||||
own: the lines round to four decimals, so a sentence built from a rounded
|
||||
number would report `0.0000 ETH` for an address holding real money. The
|
||||
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`,
|
||||
unrounded and token-aware. A balance is a warning, never a refusal.
|
||||
is destroyed, no key is deleted, funds stay where they are, and the
|
||||
address comes back by importing the recovery phrase again
|
||||
- A line naming the address's ETH balance when it holds one, stating that
|
||||
removal moves and spends nothing. A balance is a warning, never a refusal.
|
||||
- The rule that a wallet always keeps at least one address, and that
|
||||
removing the last one means deleting the wallet from Settings
|
||||
- Error line
|
||||
@@ -1063,7 +1036,7 @@ communicates with three external services to function as a wallet:
|
||||
What the extension does NOT do:
|
||||
|
||||
- No analytics or telemetry services
|
||||
- No token list APIs (the known-token list is bundled at build time)
|
||||
- No token list APIs (the top-250 token list is bundled at build time)
|
||||
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
||||
- No backend servers operated by the developer
|
||||
|
||||
@@ -1123,36 +1096,6 @@ battle-tested.
|
||||
Exceptions require explicit authorization in a code comment referencing this
|
||||
policy, but as of now there are none.
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
Both manifests declare the same policy for extension pages —
|
||||
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
|
||||
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
|
||||
a bare string in `manifest/firefox.json` (MV2).
|
||||
|
||||
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
|
||||
build and a `wasm2js` translation of it in one file, tries WASM first, and
|
||||
silently falls back to the translation if instantiation throws. Under a plain
|
||||
`script-src 'self'` the fallback was taken on every popup load, announced by
|
||||
nothing but an uncaught `CompileError`. Measured on the same Argon2id parameters
|
||||
the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a
|
||||
key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it
|
||||
is set by the ops and memory parameters, not by wall time — so the fallback
|
||||
bought nothing and cost about three and a half seconds on every operation that
|
||||
asks for the password, which is every signature.
|
||||
|
||||
The keyword permits compiling WebAssembly and nothing else: not `eval()` of
|
||||
strings, not inline script, not remote script. Using it requires already
|
||||
executing script in an extension page, which is complete compromise on its own.
|
||||
`'unsafe-eval'` is a different proposition and is not granted.
|
||||
|
||||
The grant is pinned in both directions. `tests/manifest.test.js` asserts the
|
||||
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
|
||||
20x regression on the key derivation) and adding anything beyond it both fail
|
||||
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
|
||||
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
|
||||
under the real manifest.
|
||||
|
||||
### DEBUG Mode Policy
|
||||
|
||||
The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and
|
||||
@@ -1221,8 +1164,8 @@ hardcoded test phrase.
|
||||
- Add multiple addresses within an HD wallet
|
||||
- Manage multiple wallets simultaneously
|
||||
- View ETH balance per address
|
||||
- View ERC-20 token balances (tokens on the bundled known-token list, tokens
|
||||
with 1,000 or more holders, and tokens the user adds by contract address)
|
||||
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
|
||||
holders, and tokens the user adds by contract address)
|
||||
- Send ETH to an address
|
||||
- Send ERC-20 tokens to an address
|
||||
- Receive ETH/tokens (display address, copy to clipboard, QR code)
|
||||
@@ -1280,30 +1223,26 @@ indexes it as a real token transfer.
|
||||
address. Users should always verify the full address on the confirmation
|
||||
screen before signing or sending.
|
||||
|
||||
- **Known token symbol verification**: AutistMask ships a hardcoded list of
|
||||
high-market-cap ERC-20 tokens with their legitimate contract addresses and
|
||||
symbols. The list is a point-in-time snapshot of the highest-market-cap
|
||||
Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified
|
||||
on-chain and addresses EIP-55 checksummed; `TOKENS` in
|
||||
`src/shared/tokenList.js` is the authoritative set. It is bundled at build
|
||||
time and only changes when that file is regenerated. Any token transfer
|
||||
claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
|
||||
from an unrecognized contract address is identified as a spoof and filtered
|
||||
from display. The fake "Ethereum" token in the attack above used symbol "ETH"
|
||||
from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
|
||||
match the known WETH contract — so it would be caught by this check. Detecting
|
||||
a spoof is also what adds a contract to the fraud contract blocklist below;
|
||||
that is the only thing that populates it. In the transaction history the check
|
||||
is the "Hide fake tokens impersonating a known symbol" setting, on by default;
|
||||
with it off, spoofed transfers are shown and no new blocklist entries are
|
||||
learned from them. The send-screen token selector applies the same check
|
||||
unconditionally, because it decides which tokens the user can act on rather
|
||||
than what the history displays. The balance list applies it unconditionally
|
||||
too, but not identically: it exempts symbols that `KNOWN_SYMBOLS` maps to
|
||||
`null`, and `"ETH"` is the only one. So the fake "Ethereum" token above is
|
||||
filtered from the transaction history and from the send selector, but a
|
||||
fake-`ETH` ERC-20 that clears the balance list's own 1,000-holder floor — or
|
||||
that the user tracked manually — is still shown in the balance list.
|
||||
- **Known token symbol verification**: AutistMask ships a hardcoded list of the
|
||||
top 250 ERC-20 tokens with their legitimate contract addresses and symbols.
|
||||
Any token transfer claiming a symbol from this list (e.g. "ETH", "USDT",
|
||||
"USDC") but originating from an unrecognized contract address is identified as
|
||||
a spoof and filtered from display. The fake "Ethereum" token in the attack
|
||||
above used symbol "ETH" from contract
|
||||
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known
|
||||
WETH contract — so it would be caught by this check. Detecting a spoof is also
|
||||
what adds a contract to the fraud contract blocklist below; that is the only
|
||||
thing that populates it. In the transaction history the check is the "Hide
|
||||
fake tokens impersonating a known symbol" setting, on by default; with it off,
|
||||
spoofed transfers are shown and no new blocklist entries are learned from
|
||||
them. The send-screen token selector applies the same check unconditionally,
|
||||
because it decides which tokens the user can act on rather than what the
|
||||
history displays. The balance list applies it unconditionally too, but not
|
||||
identically: it exempts symbols that `KNOWN_SYMBOLS` maps to `null`, and
|
||||
`"ETH"` is the only one. So the fake "Ethereum" token above is filtered from
|
||||
the transaction history and from the send selector, but a fake-`ETH` ERC-20
|
||||
that clears the balance list's own 1,000-holder floor — or that the user
|
||||
tracked manually — is still shown in the balance list.
|
||||
|
||||
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
|
||||
fewer than 1,000 holders are hidden from transaction history by default.
|
||||
|
||||
23
TODO.md
23
TODO.md
@@ -44,27 +44,11 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-12: An address can be removed from an HD or xprv wallet behind a
|
||||
- 2026-08-11: An address can be removed from an HD or xprv wallet behind a
|
||||
confirmation screen that states nothing is destroyed, sharing the deletion
|
||||
state transitions with wallet deletion so the selection, site permissions and
|
||||
active-address broadcast follow the same rules
|
||||
([#162](https://git.eeqj.de/sneak/AutistMask/issues/162)).
|
||||
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
|
||||
`0`, so the low-holder rule declines to judge an unknown count instead of
|
||||
hiding a legitimate token as spam, in both the transaction history and the
|
||||
Send token selector ([#230](https://git.eeqj.de/sneak/AutistMask/issues/230)).
|
||||
- 2026-08-12: Bundled token list documentation no longer states a count. The
|
||||
four "top 250" claims in `README.md` and the "roughly 500" claim in
|
||||
`docs/README.md` are replaced with a description of how the list is actually
|
||||
selected — a point-in-time CoinGecko snapshot of the highest-market-cap
|
||||
Ethereum mainnet ERC-20s — with `TOKENS` in `src/shared/tokenList.js` named as
|
||||
the authoritative set
|
||||
([#239](https://git.eeqj.de/sneak/AutistMask/issues/239)).
|
||||
- 2026-08-11: libsodium runs on WebAssembly in the shipped builds —
|
||||
`'wasm-unsafe-eval'` added to both manifest CSPs after measuring the wasm2js
|
||||
fallback at 20x the Argon2id cost, pinned in both directions by
|
||||
`tests/manifest.test.js` and observed in the real popup by the e2e suite
|
||||
([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)).
|
||||
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
|
||||
(`hideSpoofedSymbols`), on by default, governing the transaction-history
|
||||
filter and the fraud-contract learning it feeds
|
||||
@@ -76,11 +60,6 @@ undefined identifiers, which is how
|
||||
- 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well
|
||||
into Display, next to the theme selector
|
||||
([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)).
|
||||
- 2026-08-11: Network fee counted in the confirmation-screen balance check for
|
||||
both ETH and ERC-20 sends, reserving what the node actually charges a type-2
|
||||
transaction, with the arithmetic in a pure, unit-tested
|
||||
`src/shared/txValidation.js`
|
||||
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
|
||||
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
|
||||
falling back to the 100,000 gwei default, and every address comparison in
|
||||
`src/shared/transactions.js` goes through one case-normalising helper so a
|
||||
|
||||
@@ -269,10 +269,7 @@ The confirmation screen shows:
|
||||
- **From and To addresses** with identicons and Etherscan links
|
||||
- **Amount** with USD estimate
|
||||
- **Your current balance** with USD estimate
|
||||
- **Network fee** — what the transfer is expected to cost, in ETH with a USD
|
||||
estimate, and below it the larger amount reserved until it confirms. The
|
||||
reserve is what the network requires up front and what the balance check gates
|
||||
on; the refund of the difference is why the two differ
|
||||
- **Estimated network fee** in ETH with USD estimate
|
||||
- **Warnings** if the recipient is a contract, a burn address, one of your own
|
||||
addresses, on the bundled scam-address list, or labelled as a phisher on
|
||||
Etherscan
|
||||
@@ -327,19 +324,17 @@ individually removed to reset their permissions.
|
||||
AutistMask includes several defenses against common Ethereum scams, all enabled
|
||||
by default:
|
||||
|
||||
**Known token symbol verification.** AutistMask ships a bundled list of
|
||||
high-market-cap ERC-20 tokens with their legitimate contract addresses — a
|
||||
point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
|
||||
at build time and updated only when a new release ships a newer snapshot. If a
|
||||
transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
|
||||
but comes from an unrecognized contract, it is identified as a spoof and hidden.
|
||||
In your transaction history this is the "Hide fake tokens impersonating a known
|
||||
symbol" setting, which you can switch off; doing so also stops new entries being
|
||||
added to the fraud contract blocklist below, since detecting a spoof is what
|
||||
fills it. The send token list always applies the check. Your balances apply it
|
||||
too, with one exception: a token claiming the symbol "ETH" is not filtered
|
||||
there, so a fake "ETH" token can still show up in your balance list even though
|
||||
it is hidden from your transaction history and from the send token list.
|
||||
**Known token symbol verification.** AutistMask ships a list of roughly 500
|
||||
legitimate ERC-20 tokens with their contract addresses. If a transaction or
|
||||
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
|
||||
an unrecognized contract, it is identified as a spoof and hidden. In your
|
||||
transaction history this is the "Hide fake tokens impersonating a known symbol"
|
||||
setting, which you can switch off; doing so also stops new entries being added
|
||||
to the fraud contract blocklist below, since detecting a spoof is what fills it.
|
||||
The send token list always applies the check. Your balances apply it too, with
|
||||
one exception: a token claiming the symbol "ETH" is not filtered there, so a
|
||||
fake "ETH" token can still show up in your balance list even though it is hidden
|
||||
from your transaction history and from the send token list.
|
||||
|
||||
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
|
||||
from transaction history and the send token list, and are left out of your
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"description": "Minimal Ethereum wallet for Chrome",
|
||||
"permissions": ["storage", "activeTab", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/index.html"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal Ethereum wallet for Firefox",
|
||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
|
||||
"browser_action": {
|
||||
"default_popup": "src/popup/index.html"
|
||||
},
|
||||
|
||||
@@ -584,18 +584,10 @@
|
||||
<div id="confirm-balance" class="text-xs"></div>
|
||||
</div>
|
||||
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
||||
<div class="text-xs text-muted mb-1">Network fee</div>
|
||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||
<!-- Holds its one line of space from the first paint, so
|
||||
the reserve appearing when the estimate lands moves
|
||||
nothing. The placeholder is never seen. -->
|
||||
<div
|
||||
id="confirm-fee-reserve"
|
||||
class="text-xs text-muted"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
reserve pending
|
||||
<div class="text-xs text-muted mb-1">
|
||||
Estimated network fee
|
||||
</div>
|
||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-warnings"
|
||||
@@ -657,31 +649,6 @@
|
||||
class="mb-2 border border-border border-dashed p-2"
|
||||
style="visibility: hidden; min-height: 1.25rem"
|
||||
></div>
|
||||
<div
|
||||
id="confirm-amount-fee-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
Your balance does not cover this amount plus the network
|
||||
fee. Please go back and send a smaller amount.
|
||||
</div>
|
||||
<div
|
||||
id="confirm-gas-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
You do not have enough ETH to pay the network fee for this
|
||||
transfer. Please add ETH to this address and try again.
|
||||
</div>
|
||||
<div
|
||||
id="confirm-fee-unknown-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
The network fee could not be estimated, so this transaction
|
||||
cannot be checked against your balance. Please go back and
|
||||
try again.
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
@@ -1165,19 +1132,14 @@
|
||||
>
|
||||
This only stops this wallet from tracking the address.
|
||||
Nothing is destroyed and no key is deleted. Any funds at the
|
||||
address stay exactly where they are, and the address remains
|
||||
yours. Any site permissions granted to this address are
|
||||
forgotten.
|
||||
address stay exactly where they are, the address remains
|
||||
yours, and it can be brought back at any time by importing
|
||||
this wallet's recovery phrase again. Any site permissions
|
||||
granted to this address are forgotten.
|
||||
</div>
|
||||
<!-- Filled by src/popup/views/deleteAddress.js: the route
|
||||
back names the wallet's own kind of key material. -->
|
||||
<div
|
||||
id="delete-address-recovery"
|
||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||
></div>
|
||||
<div
|
||||
id="delete-address-balance"
|
||||
class="text-xs mb-2 min-h-[1.25rem] pointer-events-none"
|
||||
class="text-xs mb-2 min-h-[1.25rem]"
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,24 +32,11 @@ const {
|
||||
getFullWarnings,
|
||||
} = require("../../shared/addressWarnings");
|
||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
||||
const {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
FEE_KNOWN,
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
validateTransfer,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const txStatus = require("./txStatus");
|
||||
|
||||
let pendingTx = null;
|
||||
// Network fee for the transaction currently on screen. Reset by show() and
|
||||
// filled in by estimateGas() when the estimate resolves or fails.
|
||||
let feeStatus = FEE_PENDING;
|
||||
let feeWei = null;
|
||||
|
||||
function restore() {
|
||||
const d = state.viewData;
|
||||
@@ -80,8 +67,6 @@ function valueWithUsd(text, usdAmount) {
|
||||
|
||||
function show(txInfo) {
|
||||
pendingTx = txInfo;
|
||||
feeStatus = FEE_PENDING;
|
||||
feeWei = null;
|
||||
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||
@@ -168,14 +153,50 @@ function show(txInfo) {
|
||||
warningsEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
// The two fee messages are mutually exclusive per transaction type, and
|
||||
// the type is known here, before the first paint. Drop the one that can
|
||||
// never apply and reserve the space of the one that can, so the async
|
||||
// estimate landing later never moves anything.
|
||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||
// Check for errors
|
||||
const errors = [];
|
||||
if (isErc20) {
|
||||
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
|
||||
if (parseFloat(txInfo.amount) > tokenBal) {
|
||||
errors.push(
|
||||
"Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
|
||||
errors.push(
|
||||
"Insufficient balance. You have " +
|
||||
txInfo.balance +
|
||||
" ETH but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
);
|
||||
}
|
||||
|
||||
renderValidation(txInfo);
|
||||
const errorsEl = $("confirm-errors");
|
||||
const sendBtn = $("btn-confirm-send");
|
||||
if (errors.length > 0) {
|
||||
errorsEl.innerHTML = errors
|
||||
.map((e) => `<div class="text-xs">${e}</div>`)
|
||||
.join("");
|
||||
errorsEl.style.visibility = "visible";
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add("text-muted");
|
||||
} else {
|
||||
errorsEl.innerHTML = "";
|
||||
errorsEl.style.visibility = "hidden";
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove("text-muted");
|
||||
}
|
||||
|
||||
// Reset password field and error
|
||||
$("confirm-tx-password").value = "";
|
||||
@@ -184,7 +205,6 @@ function show(txInfo) {
|
||||
// Gas estimate — show placeholder then fetch async
|
||||
$("confirm-fee").style.visibility = "visible";
|
||||
$("confirm-fee-amount").textContent = "Estimating...";
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
state.viewData = { pendingTx: txInfo };
|
||||
showView("confirm-tx");
|
||||
attachCopyHandlers("view-confirm-tx");
|
||||
@@ -204,101 +224,11 @@ function show(txInfo) {
|
||||
checkRecipientHistory(txInfo);
|
||||
}
|
||||
|
||||
// Render the balance check for the transaction on screen. Called once during
|
||||
// show() and again when the fee estimate resolves or fails. Every element it
|
||||
// touches already occupies its space, so re-running it never moves anything.
|
||||
function renderValidation(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||
|
||||
const { canSend, codes } = validateTransfer({
|
||||
isErc20,
|
||||
amount: txInfo.amount,
|
||||
ethBalance: txInfo.balance,
|
||||
tokenBalance: txInfo.tokenBalance,
|
||||
feeStatus,
|
||||
feeWei,
|
||||
});
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html.
|
||||
const messages = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
messages.push("Please enter a valid amount to send.");
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||
messages.push(
|
||||
"Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||
messages.push(
|
||||
"Insufficient balance. You have " +
|
||||
txInfo.balance +
|
||||
" ETH but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
);
|
||||
}
|
||||
|
||||
const errorsEl = $("confirm-errors");
|
||||
if (messages.length > 0) {
|
||||
errorsEl.innerHTML = messages
|
||||
.map((m) => `<div class="text-xs">${escapeHtml(m)}</div>`)
|
||||
.join("");
|
||||
errorsEl.style.visibility = "visible";
|
||||
} else {
|
||||
errorsEl.innerHTML = "";
|
||||
errorsEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
setVisible(
|
||||
"confirm-amount-fee-error",
|
||||
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-gas-error",
|
||||
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-fee-unknown-error",
|
||||
codes.includes(CODES.FEE_UNAVAILABLE),
|
||||
);
|
||||
|
||||
// While the estimate is in flight there is no error to show — the fee
|
||||
// line already reads "Estimating..." — but sending stays blocked so a
|
||||
// transaction the fee would break cannot be signed in the meantime.
|
||||
const sendBtn = $("btn-confirm-send");
|
||||
sendBtn.disabled = !canSend;
|
||||
sendBtn.classList.toggle("text-muted", !canSend);
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
// A fee in wei as an ETH string, truncated to 6 decimal places.
|
||||
function formatFeeEth(wei) {
|
||||
const parts = formatEther(wei).split(".");
|
||||
const dec =
|
||||
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
|
||||
return parts[0] + "." + dec + " ETH";
|
||||
}
|
||||
|
||||
async function estimateGas(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const feeData = await provider.getFeeData();
|
||||
const gasPrice = feeData.gasPrice;
|
||||
let gasLimit;
|
||||
|
||||
if (txInfo.token === "ETH") {
|
||||
@@ -316,55 +246,21 @@ async function estimateGas(txInfo) {
|
||||
});
|
||||
}
|
||||
|
||||
// What the node will require to be reserved, which is what the gate
|
||||
// must be: the send pins no fee fields, so it is broadcast as a
|
||||
// type-2 transaction priced at maxFeePerGas.
|
||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||
if (gasCostWei === null) {
|
||||
throw new Error("no usable gas price from the provider");
|
||||
}
|
||||
// What the transaction is expected to cost, which is a different and
|
||||
// usually much smaller number. Both are shown: quoting only the
|
||||
// reserve overstates the typical cost by roughly double on mainnet,
|
||||
// and quoting only the estimate contradicts the balance check.
|
||||
const estimateWei = feeEstimateWei(gasLimit, feeData);
|
||||
// The user may have left this transaction while the estimate was in
|
||||
// flight; a stale fee must not reach the screen or the balance check.
|
||||
if (pendingTx !== txInfo) return;
|
||||
|
||||
const gasCostWei = gasLimit * gasPrice;
|
||||
const gasCostEth = formatEther(gasCostWei);
|
||||
// Format to 6 significant decimal places
|
||||
const parts = gasCostEth.split(".");
|
||||
const dec =
|
||||
parts.length > 1
|
||||
? parts[1].slice(0, 6).replace(/0+$/, "") || "0"
|
||||
: "0";
|
||||
const feeStr = parts[0] + "." + dec + " ETH";
|
||||
const ethPrice = getPrice("ETH");
|
||||
const usd = (wei) =>
|
||||
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
||||
|
||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
"~" + formatFeeEth(estimateWei),
|
||||
usd(estimateWei),
|
||||
);
|
||||
$("confirm-fee-reserve").textContent =
|
||||
"up to " + formatFeeEth(gasCostWei) + " 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 = valueWithUsd(
|
||||
formatFeeEth(gasCostWei),
|
||||
usd(gasCostWei),
|
||||
);
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
}
|
||||
feeStatus = FEE_KNOWN;
|
||||
feeWei = gasCostWei;
|
||||
renderValidation(txInfo);
|
||||
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
||||
} catch (e) {
|
||||
log.errorf("gas estimation failed:", e.message);
|
||||
if (pendingTx !== txInfo) return;
|
||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
feeStatus = FEE_UNAVAILABLE;
|
||||
feeWei = null;
|
||||
renderValidation(txInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,7 @@ const {
|
||||
goBack,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
addressHoldsFunds,
|
||||
balanceLinesForAddress,
|
||||
} = require("./helpers");
|
||||
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const {
|
||||
canRemoveAddress,
|
||||
@@ -37,71 +33,6 @@ function setFlash(msg) {
|
||||
el.style.visibility = msg ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
// What it actually takes to get the address back, which is not what the
|
||||
// screen used to claim.
|
||||
//
|
||||
// Neither obvious route works: "+" derives the next unused index, because
|
||||
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and
|
||||
// re-importing this wallet's key material is refused as a duplicate by
|
||||
// findWalletByXpub() for as long as the wallet is here. What remains is to
|
||||
// delete the whole wallet in Settings — which asks for the password and
|
||||
// destroys the stored secret — and import again, after which
|
||||
// scanForAddresses() rediscovers the address only if it has on-chain
|
||||
// activity. An address that was never used is not found by that scan, and
|
||||
// the copy must not imply otherwise.
|
||||
//
|
||||
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
|
||||
// this screen is offered on xprv wallets too.
|
||||
function recoveryPathText(wallet) {
|
||||
const secret = walletHasRecoveryPhrase(wallet)
|
||||
? "recovery phrase"
|
||||
: "extended private key";
|
||||
return (
|
||||
"Getting the address back into this list is not easy, so be sure. " +
|
||||
"Adding an address derives the next unused one, not this one, and " +
|
||||
"importing this " +
|
||||
secret +
|
||||
" again is refused while this wallet is still here. The way back is " +
|
||||
"to delete the whole wallet in Settings, which asks for your " +
|
||||
"password and destroys the stored " +
|
||||
secret +
|
||||
", and then import that " +
|
||||
secret +
|
||||
" again. The scan that follows only finds addresses that have " +
|
||||
"on-chain activity, so an address that has never been used is not " +
|
||||
"found by it."
|
||||
);
|
||||
}
|
||||
|
||||
// The balance warning, or a blank line when the address holds nothing.
|
||||
//
|
||||
// A balance is a reason to be careful, not a reason to refuse: the funds are
|
||||
// at the address, not in this list, and stay there either way.
|
||||
//
|
||||
// "Holds" means ETH or any ERC-20 the wallet knows about — an address with no
|
||||
// ETH and a five-figure stablecoin position must not get the blank line on
|
||||
// the one screen whose job is to warn. The sentence names no figure of its
|
||||
// own: the rendered lines round to four decimals, so a sentence built from a
|
||||
// rounded number would report "0.0000 ETH" for an address holding real money.
|
||||
// The lines below it carry the amounts, in the same format as Home and
|
||||
// AddressDetail, followed by the USD total when prices are known (null on
|
||||
// testnet and before the first price fetch, where the line is left off rather
|
||||
// than printed as $0.00).
|
||||
function balanceWarningHtml(addr) {
|
||||
if (!addressHoldsFunds(addr)) return " ";
|
||||
const usd = getAddressValueUsd(addr);
|
||||
const total =
|
||||
usd === null
|
||||
? ""
|
||||
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
|
||||
return (
|
||||
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
||||
`move or spend anything; the balance stays at the address.</p>` +
|
||||
balanceLinesForAddress(addr, state.trackedTokens, false) +
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
function show(walletIdx, addrIdx) {
|
||||
const wallet = state.wallets[walletIdx];
|
||||
const addr = wallet && wallet.addresses[addrIdx];
|
||||
@@ -118,8 +49,16 @@ function show(walletIdx, addrIdx) {
|
||||
});
|
||||
attachCopyHandlers(value);
|
||||
|
||||
$("delete-address-recovery").textContent = recoveryPathText(wallet);
|
||||
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
|
||||
// A balance is a reason to be careful, not a reason to refuse: the funds
|
||||
// are at the address, not in this list, and stay there either way.
|
||||
const balance = parseFloat(addr.balance || "0");
|
||||
$("delete-address-balance").innerHTML =
|
||||
balance > 0
|
||||
? "This address holds " +
|
||||
balance.toFixed(4) +
|
||||
" ETH. Removing it does not move or spend anything; the balance " +
|
||||
"stays at the address."
|
||||
: " ";
|
||||
|
||||
setFlash("");
|
||||
showView("delete-address-confirm");
|
||||
@@ -170,7 +109,4 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
// recoveryPathText and balanceWarningHtml are exported so the two pieces of
|
||||
// copy that carry the screen's substance can be tested without a DOM; show()
|
||||
// is a one-line assignment for each.
|
||||
module.exports = { init, show, recoveryPathText, balanceWarningHtml };
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -218,20 +218,6 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
||||
// knows about. Deliberately unrounded — the rendered lines round to four
|
||||
// decimals, so a dust balance displays as 0.0000 while still being real
|
||||
// money at a real address. Callers that warn about holdings must ask this,
|
||||
// not the rendered figure.
|
||||
function addressHoldsFunds(addr) {
|
||||
if (!addr) return false;
|
||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (parseFloat(t.balance || "0") > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Truncate the middle of a string, replacing removed characters with "…".
|
||||
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
||||
// that still prevents address spoofing attacks (see Display Consistency in
|
||||
@@ -478,7 +464,6 @@ module.exports = {
|
||||
flashCopyFeedback,
|
||||
balanceLine,
|
||||
balanceLinesForAddress,
|
||||
addressHoldsFunds,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
|
||||
@@ -13,7 +13,6 @@ const { state, currentAddress } = require("../../shared/state");
|
||||
let ctx;
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { getAddress } = require("ethers");
|
||||
|
||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
@@ -133,10 +132,7 @@ function renderSendTokenSelect(addr) {
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (isSpoofedToken(t)) continue;
|
||||
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||
// An unknown holder count does not withhold a token the user holds:
|
||||
// only a count the explorer actually reported as below the threshold
|
||||
// does. Otherwise a missing field makes a real asset unspendable.
|
||||
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
|
||||
if (state.hideLowHolderTokens && (t.holders || 0) < 1000) continue;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = t.address;
|
||||
opt.textContent = t.symbol;
|
||||
|
||||
@@ -12,7 +12,6 @@ const { ERC20_ABI } = require("./constants");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||
|
||||
// Use a static network to skip auto-detection (which can fail and cause
|
||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||
@@ -71,20 +70,10 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
if (bal === "0.0") continue;
|
||||
|
||||
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
||||
// 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 holders = parseInt(item.token.holders_count || "0", 10);
|
||||
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||
const isTracked = trackedSet.has(tokenAddr);
|
||||
const hasEnoughHolders =
|
||||
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
||||
const hasEnoughHolders = holders >= 1000;
|
||||
|
||||
// Skip spam tokens the user never asked to see
|
||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||
@@ -289,7 +278,6 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTokenBalances,
|
||||
refreshBalances,
|
||||
lookupTokenInfo,
|
||||
getProvider,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Holder counts, and the one rule that decides whether a count is "low".
|
||||
//
|
||||
// The block explorer's holders_count is optional: it is absent on a token it
|
||||
// has only just indexed, and it goes missing on a degraded or changed API.
|
||||
// Absent means the count is unknown. It does not mean the token has no
|
||||
// holders, and collapsing the two hides a token the user really holds as if
|
||||
// it were spam. Every call site reads the count through here so the
|
||||
// distinction cannot be lost again in one place while holding in the others.
|
||||
|
||||
const LOW_HOLDER_THRESHOLD = 1000;
|
||||
|
||||
// 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 (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
|
||||
// the threshold. An unknown count is never low: showing a spam token the
|
||||
// user can see is unusual costs less than hiding an asset they own.
|
||||
function isLowHolderCount(holders) {
|
||||
return holders != null && holders < LOW_HOLDER_THRESHOLD;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOW_HOLDER_THRESHOLD,
|
||||
parseHoldersCount,
|
||||
isLowHolderCount,
|
||||
};
|
||||
@@ -9,7 +9,6 @@
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity. Every address comparison in
|
||||
@@ -117,10 +116,7 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
contractAddress: normalizeAddress(
|
||||
tt.token?.address_hash || tt.token?.address || "",
|
||||
),
|
||||
// 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),
|
||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,13 +292,12 @@ function filterTransactions(txs, filters = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter low-holder tokens (<1000) if setting is on. A token whose
|
||||
// holder count the explorer did not report is kept: only a reported
|
||||
// count below the threshold is "low".
|
||||
// Filter low-holder tokens (<1000) if setting is on
|
||||
if (
|
||||
filters.hideLowHolderTokens &&
|
||||
tx.contractAddress &&
|
||||
isLowHolderCount(tx.holders)
|
||||
tx.holders !== null &&
|
||||
tx.holders < 1000
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
// Balance arithmetic for the transaction confirmation screen.
|
||||
//
|
||||
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||
// instead of through the confirmation view. The caller maps the returned
|
||||
// codes to the reserved message elements on the screen.
|
||||
//
|
||||
// Human decimal strings ("1.25") are scaled to 18 decimals for comparison.
|
||||
// That scale is independent of a token's own decimals: both the amount and
|
||||
// the token balance arrive as human decimal strings, so comparing them at a
|
||||
// common scale is exact.
|
||||
|
||||
const { parseUnits } = require("ethers");
|
||||
|
||||
const SCALE_DECIMALS = 18;
|
||||
|
||||
// Whether the asynchronous fee estimate has arrived yet.
|
||||
const FEE_PENDING = "pending";
|
||||
const FEE_KNOWN = "known";
|
||||
const FEE_UNAVAILABLE = "unavailable";
|
||||
|
||||
const CODES = {
|
||||
// The amount is not a non-negative number we can do exact arithmetic on.
|
||||
AMOUNT_INVALID: "amount-invalid",
|
||||
// ERC-20: the token amount exceeds the token balance.
|
||||
INSUFFICIENT_TOKEN: "insufficient-token",
|
||||
// ETH: the amount alone already exceeds the ETH balance.
|
||||
INSUFFICIENT_ETH: "insufficient-eth",
|
||||
// ETH: the amount fits, the amount plus the network fee does not.
|
||||
INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee",
|
||||
// ERC-20: the token balance covers the transfer, the ETH balance does
|
||||
// not cover the network fee it costs.
|
||||
INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee",
|
||||
// The fee estimate has not arrived yet.
|
||||
FEE_PENDING: "fee-pending",
|
||||
// The fee estimate failed. Unknown is never treated as zero.
|
||||
FEE_UNAVAILABLE: "fee-unavailable",
|
||||
};
|
||||
|
||||
// The fee that must be reserved for a transaction, in wei: the amount the
|
||||
// node will require, not the amount the transaction is expected to cost.
|
||||
//
|
||||
// A send that pins no fee fields is populated by ethers as a type-2
|
||||
// (EIP-1559) transaction, and a node validates that against
|
||||
// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as
|
||||
// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice`
|
||||
// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and
|
||||
// lets through a transaction the node then rejects with "insufficient funds
|
||||
// for gas * price + value". gasPrice is the fallback only for a network that
|
||||
// offers no type-2 pricing at all.
|
||||
//
|
||||
// Returns null when no usable price is available, which the caller must treat
|
||||
// as a failed estimate rather than as a free transaction.
|
||||
function feeReserveWei(gasLimit, feeData) {
|
||||
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
|
||||
const price = feeData?.maxFeePerGas ?? feeData?.gasPrice;
|
||||
if (typeof price !== "bigint" || price < 0n) return null;
|
||||
return gasLimit * price;
|
||||
}
|
||||
|
||||
// What the transaction is expected to actually cost, in wei — not what must
|
||||
// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas
|
||||
// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that,
|
||||
// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network
|
||||
// with no type-2 pricing the two are the same number.
|
||||
//
|
||||
// Display only: nothing gates on this. Returns null on the same unusable
|
||||
// inputs as feeReserveWei().
|
||||
function feeEstimateWei(gasLimit, feeData) {
|
||||
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
|
||||
const price = feeData?.gasPrice ?? feeData?.maxFeePerGas;
|
||||
if (typeof price !== "bigint" || price < 0n) return null;
|
||||
return gasLimit * price;
|
||||
}
|
||||
|
||||
// Scale a human decimal string to 18-decimal fixed point. Returns null when
|
||||
// the value is not a decimal number or carries more precision than the scale
|
||||
// can hold, which the caller must treat as unusable rather than as zero.
|
||||
function toFixedPoint(value) {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const text = String(value).trim();
|
||||
if (text === "") return null;
|
||||
try {
|
||||
return parseUnits(text, SCALE_DECIMALS);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate a pending transfer against the balances that must cover it.
|
||||
//
|
||||
// isErc20 — token transfer rather than a native ETH transfer
|
||||
// amount — human decimal string being sent, non-negative. Anything
|
||||
// else, a negative value included, is an unusable amount
|
||||
// rather than an amount that passes every comparison.
|
||||
// ethBalance — human decimal string, the sender's ETH balance
|
||||
// tokenBalance — human decimal string, the sender's token balance
|
||||
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
||||
// is treated as FEE_UNAVAILABLE.
|
||||
// feeWei — the fee reserve in wei from feeReserveWei(), as a
|
||||
// non-negative bigint, when FEE_KNOWN. Any other value makes
|
||||
// the fee unavailable rather than zero.
|
||||
//
|
||||
// Returns { canSend, codes }. Every code blocks sending: canSend is true
|
||||
// only when nothing was found.
|
||||
function validateTransfer({
|
||||
isErc20 = false,
|
||||
amount,
|
||||
ethBalance,
|
||||
tokenBalance,
|
||||
feeStatus = FEE_PENDING,
|
||||
feeWei = null,
|
||||
} = {}) {
|
||||
const codes = [];
|
||||
|
||||
const amountFp = toFixedPoint(amount);
|
||||
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
||||
|
||||
// A negative amount parses to a valid bigint, so every comparison below
|
||||
// is trivially false and the send clears the screen — then dies at encode
|
||||
// time in parseEther(). Unusable, on the same footing as a malformed fee.
|
||||
if (amountFp === null || amountFp < 0n) {
|
||||
codes.push(CODES.AMOUNT_INVALID);
|
||||
return { canSend: false, codes };
|
||||
}
|
||||
|
||||
// Fail closed. Anything that is not a usable fee under a recognised
|
||||
// status — a malformed feeWei, or a status this module does not know —
|
||||
// is an unavailable estimate, never a fee of zero. Every such input errs
|
||||
// in the direction that lets money out, so none of them is trusted.
|
||||
const known =
|
||||
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n;
|
||||
let status = feeStatus;
|
||||
if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE;
|
||||
if (status !== FEE_KNOWN && status !== FEE_PENDING) {
|
||||
status = FEE_UNAVAILABLE;
|
||||
}
|
||||
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
|
||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||
if (feeFp !== null && feeFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
||||
}
|
||||
} else if (amountFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH);
|
||||
} else if (feeFp !== null && amountFp + feeFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE);
|
||||
}
|
||||
|
||||
// An unknown fee is never assumed to be zero: sending stays blocked
|
||||
// until the estimate arrives, and stays blocked if it never does.
|
||||
if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING);
|
||||
if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
|
||||
|
||||
return { canSend: codes.length === 0, codes };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
FEE_KNOWN,
|
||||
FEE_UNAVAILABLE,
|
||||
SCALE_DECIMALS,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
};
|
||||
@@ -1,80 +1,14 @@
|
||||
// Vault: password-based encryption of secrets using libsodium.
|
||||
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
|
||||
// All crypto operations are delegated to libsodium — no raw primitives.
|
||||
//
|
||||
// Backend: WebAssembly, deliberately (#182).
|
||||
//
|
||||
// libsodium ships one file containing both a WebAssembly build and a
|
||||
// wasm2js ("asm.js") translation of it. It tries WASM first and, if
|
||||
// instantiation throws, silently swaps in the translation. An extension
|
||||
// CSP of plain script-src 'self' refuses WASM, so every popup load used
|
||||
// to take that fallback — announced by nothing but an uncaught
|
||||
// CompileError in the console.
|
||||
//
|
||||
// Measured here, same Argon2id parameters (OPSLIMIT_INTERACTIVE,
|
||||
// MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22 on this machine:
|
||||
// WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is
|
||||
// identical either way — it is set by the ops/mem parameters, not by wall
|
||||
// time — so the fallback bought no security, it only made every password
|
||||
// operation take three and a half seconds, and the wallet asks for the
|
||||
// password on every signature.
|
||||
//
|
||||
// So both manifests declare 'wasm-unsafe-eval' for extension pages. That
|
||||
// keyword permits compiling WebAssembly and nothing else: not eval() of
|
||||
// strings, not inline script, not remote script. Reaching it requires
|
||||
// already executing script in the extension page, which is total
|
||||
// compromise on its own. 'unsafe-eval' would be a different matter and is
|
||||
// not granted. tests/manifest.test.js pins both policies to exactly
|
||||
// "'self' 'wasm-unsafe-eval'" so neither the grant nor the surrounding
|
||||
// strictness can drift unnoticed.
|
||||
//
|
||||
// The fallback still exists, and a wallet that refuses to decrypt is
|
||||
// worse than a slow one, so it is not disabled — it is made loud:
|
||||
// cryptoBackend() reports which backend this realm can run, ensureReady()
|
||||
// logs an error if it is not WASM, tests/vaultBackend.test.js asserts the
|
||||
// unit tests exercise the WASM backend, and the end-to-end suite asserts
|
||||
// it in the real popup under the real manifest.
|
||||
|
||||
const sodium = require("libsodium-wrappers-sumo");
|
||||
const { log } = require("./log");
|
||||
|
||||
// An empty WebAssembly module: the 8-byte magic number and version header,
|
||||
// no sections. Compiling it asks the cheapest possible form of the only
|
||||
// question that matters here — may this realm compile WebAssembly at all —
|
||||
// which is exactly what a CSP without 'wasm-unsafe-eval' refuses, and
|
||||
// exactly what decides which backend libsodium ends up on.
|
||||
const EMPTY_WASM_MODULE = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
// "wasm" or "asmjs": whether this realm may compile WebAssembly, which is
|
||||
// what decides libsodium's backend when the CSP is the reason it cannot —
|
||||
// the case this codebase guards. It probes the realm, not libsodium, so a
|
||||
// fallback taken for some other reason (allocation failure, corrupt module)
|
||||
// would not be caught here; tests/vaultBackend.test.js checks libsodium's
|
||||
// own marker directly.
|
||||
async function cryptoBackend() {
|
||||
try {
|
||||
await WebAssembly.compile(EMPTY_WASM_MODULE);
|
||||
return "wasm";
|
||||
} catch (_) {
|
||||
return "asmjs";
|
||||
}
|
||||
}
|
||||
|
||||
let ready = false;
|
||||
|
||||
async function ensureReady() {
|
||||
if (!ready) {
|
||||
await sodium.ready;
|
||||
if ((await cryptoBackend()) !== "wasm") {
|
||||
log.errorf(
|
||||
"libsodium is running on the wasm2js fallback: this realm " +
|
||||
"refuses to compile WebAssembly, so every password " +
|
||||
"derivation costs roughly 20x what it should. See the " +
|
||||
"backend note in src/shared/vault.js.",
|
||||
);
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
@@ -125,4 +59,4 @@ async function decryptWithPassword(encrypted, password) {
|
||||
return sodium.to_string(plaintext);
|
||||
}
|
||||
|
||||
module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };
|
||||
module.exports = { encryptWithPassword, decryptWithPassword };
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
// Tests for the copy on the address-removal confirmation (issue #162).
|
||||
//
|
||||
// The screen's whole job is to warn before a destructive-looking action, so
|
||||
// the copy is the substance and is tested as such. Two things it must not
|
||||
// get wrong: what it takes to get the address back — the app refuses both
|
||||
// obvious routes — and what counts as holding something, which is any
|
||||
// ERC-20 as well as ETH, at any size, including a balance that rounds to
|
||||
// zero at the four decimals the balance lines render. The DOM behaviour
|
||||
// around them is driven against the real popup by tests/e2e/run.js.
|
||||
|
||||
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { addressHoldsFunds } = require("../src/popup/views/helpers");
|
||||
const {
|
||||
recoveryPathText,
|
||||
balanceWarningHtml,
|
||||
} = require("../src/popup/views/deleteAddress");
|
||||
const { prices, clearPrices } = require("../src/shared/prices");
|
||||
|
||||
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||
|
||||
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
|
||||
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
|
||||
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
|
||||
const TOKEN_ONLY = {
|
||||
address: "0x1",
|
||||
balance: "0.0000",
|
||||
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
|
||||
};
|
||||
const ZERO_TOKEN = {
|
||||
address: "0x1",
|
||||
balance: "0",
|
||||
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "0" }],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
clearPrices();
|
||||
});
|
||||
|
||||
describe("what the screen says it takes to get the address back", () => {
|
||||
// The screen used to promise the address "can be brought back at any
|
||||
// time by importing this wallet's recovery phrase again". That import is
|
||||
// refused as a duplicate for as long as the wallet is present, which it
|
||||
// always is here — a wallet never gives up its last address.
|
||||
test("it does not promise a re-import while the wallet is here", () => {
|
||||
const text = recoveryPathText({ type: "hd" });
|
||||
expect(text).not.toMatch(/at any time/);
|
||||
expect(text).toContain("is refused while this wallet is still here");
|
||||
});
|
||||
|
||||
test("it names deleting the whole wallet as the route back", () => {
|
||||
expect(recoveryPathText({ type: "hd" })).toContain(
|
||||
"delete the whole wallet in Settings",
|
||||
);
|
||||
});
|
||||
|
||||
// The scan after a re-import finds used addresses only, so an address
|
||||
// that never saw a transaction does not come back at all. Saying so is
|
||||
// the difference between a warning and a false reassurance.
|
||||
test("it states the limit: only on-chain activity is found", () => {
|
||||
const text = recoveryPathText({ type: "hd" });
|
||||
expect(text).toContain("only finds addresses that have on-chain");
|
||||
expect(text).toContain("never been used is not found by it");
|
||||
});
|
||||
|
||||
// The screen is offered on xprv wallets too, and an xprv wallet holds no
|
||||
// recovery phrase — telling its owner to import one would send them
|
||||
// looking for words that do not exist.
|
||||
test("an xprv wallet is told about its extended private key", () => {
|
||||
const text = recoveryPathText({ type: "xprv" });
|
||||
expect(text).toContain("extended private key");
|
||||
expect(text).not.toContain("recovery phrase");
|
||||
});
|
||||
|
||||
test("an HD wallet is told about its recovery phrase", () => {
|
||||
const text = recoveryPathText({ type: "hd" });
|
||||
expect(text).toContain("recovery phrase");
|
||||
expect(text).not.toContain("extended private key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("whether an address holds anything", () => {
|
||||
test("ETH counts", () => {
|
||||
expect(addressHoldsFunds(ETH_ONLY)).toBe(true);
|
||||
});
|
||||
|
||||
// The case that decides the screen: no ETH at all, and $2500 of a
|
||||
// stablecoin sitting at the address.
|
||||
test("an ERC-20 balance counts even with no ETH", () => {
|
||||
expect(addressHoldsFunds(TOKEN_ONLY)).toBe(true);
|
||||
});
|
||||
|
||||
// 0.00001 ETH renders as "0.0000" at four decimals. It is still money.
|
||||
test("an ETH balance below the displayed precision counts", () => {
|
||||
expect(addressHoldsFunds(DUST)).toBe(true);
|
||||
});
|
||||
|
||||
test("an address holding nothing does not", () => {
|
||||
expect(addressHoldsFunds(EMPTY)).toBe(false);
|
||||
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
test("a missing address or missing fields do not", () => {
|
||||
expect(addressHoldsFunds(undefined)).toBe(false);
|
||||
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the balance warning on the removal confirmation", () => {
|
||||
test("an address holding nothing gets a blank line, not a warning", () => {
|
||||
expect(balanceWarningHtml(EMPTY)).toBe(" ");
|
||||
expect(balanceWarningHtml(ZERO_TOKEN)).toBe(" ");
|
||||
});
|
||||
|
||||
test("an ERC-20-only address is warned about, and its token listed", () => {
|
||||
const html = balanceWarningHtml(TOKEN_ONLY);
|
||||
expect(html).toContain("This address holds a balance.");
|
||||
expect(html).toContain("does not move or spend anything");
|
||||
expect(html).toContain("USDC");
|
||||
expect(html).toContain("2500.0000");
|
||||
});
|
||||
|
||||
// The rendered line says 0.0000 for this address — that is the display
|
||||
// format, shared with Home and AddressDetail — and the warning is shown
|
||||
// all the same, because the balance is not zero.
|
||||
test("an ETH balance that renders as 0.0000 is warned about", () => {
|
||||
const html = balanceWarningHtml(DUST);
|
||||
expect(html).toContain("This address holds a balance.");
|
||||
expect(html).toContain("<span>0.0000</span>");
|
||||
});
|
||||
|
||||
// The sentence must not assert an amount, because any amount it could
|
||||
// assert has been rounded: "This address holds 0.0000 ETH." is what the
|
||||
// rounded form produces for an address that holds real money.
|
||||
test("the warning sentence asserts no rounded amount", () => {
|
||||
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
|
||||
expect(balanceWarningHtml(addr)).not.toMatch(
|
||||
/holds [\d.]+ (ETH|USDC)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the USD total is shown when prices are known", () => {
|
||||
prices.ETH = 2000;
|
||||
prices.USDC = 1;
|
||||
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
|
||||
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
|
||||
});
|
||||
|
||||
// getAddressValueUsd() returns null on testnet and before the first
|
||||
// price fetch. A "Total: $0.00" there would be a lie about the holdings.
|
||||
test("no USD total is shown when prices are not known", () => {
|
||||
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");
|
||||
});
|
||||
});
|
||||
@@ -22,12 +22,18 @@ const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
||||
// entry must name the issue that will remove it. This list is the one
|
||||
// concession in an otherwise zero-tolerance policy: an uncaught error is
|
||||
// how this harness caught issue #150 in the first place.
|
||||
//
|
||||
// Empty, and worth keeping that way. Its only entry was the WASM
|
||||
// CompileError libsodium provoked on every popup load, deleted with #182
|
||||
// when both manifests started allowing WASM; the run that used to need it
|
||||
// is now the run that proves the fix.
|
||||
const ALLOWED_ERRORS = [];
|
||||
const ALLOWED_ERRORS = [
|
||||
{
|
||||
// libsodium ships a WASM build and an asm.js fallback. The
|
||||
// extension CSP (script-src 'self', with no wasm-unsafe-eval)
|
||||
// refuses the WASM module on every popup load; libsodium catches
|
||||
// it and falls back to asm.js, so the wallet works. Deciding
|
||||
// which backend actually ships is issue #182, and this entry gets
|
||||
// deleted when that lands.
|
||||
issue: "#182",
|
||||
pattern: /Refused to compile or instantiate WebAssembly module/,
|
||||
},
|
||||
];
|
||||
|
||||
function isAllowed(text) {
|
||||
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
|
||||
@@ -241,26 +247,6 @@ async function visible(page, selector, timeout = 15000) {
|
||||
await page.waitForSelector(selector, { state: "visible", timeout });
|
||||
}
|
||||
|
||||
// An empty WebAssembly module: magic number and version header, no
|
||||
// sections. Compiling it in the popup asks the one question that decides
|
||||
// libsodium's backend — may this realm compile WebAssembly — of the real
|
||||
// page under the real shipped manifest, which is the only place the
|
||||
// answer can be observed. Kept independent of src/shared/vault.js on
|
||||
// purpose: a bundle asked to grade itself proves less than an outside
|
||||
// observation of the same realm.
|
||||
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
|
||||
|
||||
async function pageCompilesWasm(page) {
|
||||
return page.evaluate(async (bytes) => {
|
||||
try {
|
||||
await WebAssembly.compile(new Uint8Array(bytes));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}, EMPTY_WASM_MODULE);
|
||||
}
|
||||
|
||||
async function openPopup(ctx, popupUrl) {
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(popupUrl);
|
||||
@@ -307,6 +293,5 @@ module.exports = {
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
pageCompilesWasm,
|
||||
visible,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
pageCompilesWasm,
|
||||
visible,
|
||||
} = require("./harness");
|
||||
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
||||
@@ -61,27 +60,6 @@ test("popup loads and reaches the welcome view", async (env) => {
|
||||
assert(title === "AutistMask", "unexpected popup title: " + title);
|
||||
});
|
||||
|
||||
// The empirical half of #182. The manifest change is only a claim about
|
||||
// what the CSP permits; this is the observation. Two things have to hold
|
||||
// together, and the run covers both: the popup realm compiles WASM (here),
|
||||
// and no WASM refusal or abort is recorded anywhere in the run — the
|
||||
// harness allowlist that used to excuse exactly that error is now empty,
|
||||
// so a recurrence fails whichever test it lands in rather than being
|
||||
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
||||
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
||||
// takes the WASM path, and the next test drives a real vault encryption
|
||||
// through it.
|
||||
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
||||
const ok = await pageCompilesWasm(env.page);
|
||||
assert(
|
||||
ok,
|
||||
"the popup refused to compile WebAssembly. The shipped manifest CSP " +
|
||||
"has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
|
||||
"fallback and every password derivation costs roughly 20x what it " +
|
||||
"should — see the backend note in src/shared/vault.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("wallet creation through the UI reaches the main view", async (env) => {
|
||||
env.phrase = await createWallet(env.page);
|
||||
assert(
|
||||
@@ -455,20 +433,6 @@ test("leaving the removal confirmation removes nothing (#162)", async (env) => {
|
||||
"the confirmation names the wrong address: " + JSON.stringify(label),
|
||||
);
|
||||
|
||||
// The route back is written by the view, not by index.html, so an empty
|
||||
// paragraph here means the user is confirming with no idea what it
|
||||
// takes to undo. This wallet is an HD one, so it is told about its
|
||||
// recovery phrase.
|
||||
const recovery = await env.page
|
||||
.locator("#delete-address-recovery")
|
||||
.innerText();
|
||||
assert(
|
||||
recovery.includes("delete the whole wallet in Settings") &&
|
||||
recovery.includes("recovery phrase"),
|
||||
"the confirmation does not state the route back: " +
|
||||
JSON.stringify(recovery),
|
||||
);
|
||||
|
||||
// "Back" re-renders Home, so a count taken after it is a real
|
||||
// measurement of the wallet rather than a stale screen.
|
||||
await env.page.click("#btn-delete-address-back");
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// Tests for src/shared/holders.js and the balance-list spam gate that reads
|
||||
// it (issue #230).
|
||||
//
|
||||
// The rule these pin down: an explorer that reports no holders_count has told
|
||||
// us nothing, and "nothing" must not be recorded as "zero holders". Zero is
|
||||
// the strongest spam signal the wallet has, so handing it out for free turns
|
||||
// a missing field into a hidden asset.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
global.chrome = { storage: { local: {} } };
|
||||
|
||||
const {
|
||||
LOW_HOLDER_THRESHOLD,
|
||||
parseHoldersCount,
|
||||
isLowHolderCount,
|
||||
} = require("../src/shared/holders");
|
||||
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||
const { debugFetch } = require("../src/shared/log");
|
||||
|
||||
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
|
||||
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
describe("parseHoldersCount", () => {
|
||||
test("a reported count parses to that number", () => {
|
||||
expect(parseHoldersCount("3500000")).toBe(3500000);
|
||||
expect(parseHoldersCount(3500000)).toBe(3500000);
|
||||
});
|
||||
|
||||
test('a reported "0" parses to 0, which is not null', () => {
|
||||
expect(parseHoldersCount("0")).toBe(0);
|
||||
expect(parseHoldersCount(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("an omitted, null or empty count is unknown", () => {
|
||||
expect(parseHoldersCount(undefined)).toBeNull();
|
||||
expect(parseHoldersCount(null)).toBeNull();
|
||||
expect(parseHoldersCount("")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unparseable count is unknown rather than zero", () => {
|
||||
expect(parseHoldersCount("many")).toBeNull();
|
||||
expect(parseHoldersCount(NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLowHolderCount", () => {
|
||||
test("the threshold is the documented 1,000 holders", () => {
|
||||
expect(LOW_HOLDER_THRESHOLD).toBe(1000);
|
||||
});
|
||||
|
||||
test("a reported count below the threshold is low", () => {
|
||||
expect(isLowHolderCount(0)).toBe(true);
|
||||
expect(isLowHolderCount(999)).toBe(true);
|
||||
});
|
||||
|
||||
test("a reported count at or above the threshold is not low", () => {
|
||||
expect(isLowHolderCount(1000)).toBe(false);
|
||||
expect(isLowHolderCount(1001)).toBe(false);
|
||||
});
|
||||
|
||||
test("an unknown count is not low", () => {
|
||||
expect(isLowHolderCount(null)).toBe(false);
|
||||
expect(isLowHolderCount(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// fetchTokenBalances applies its own spam gate, which is not the low-holder
|
||||
// display filter: it has no setting behind it and decides what the balance
|
||||
// list contains at all. It stays strict on an unknown count — see the
|
||||
// comment at the gate — but must stop recording that unknown as zero.
|
||||
describe("the balance-list spam gate", () => {
|
||||
function respondWith(items) {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => items,
|
||||
}));
|
||||
}
|
||||
|
||||
function item(overrides = {}) {
|
||||
const { token, ...rest } = overrides;
|
||||
return {
|
||||
value: "12500000",
|
||||
...rest,
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: NOVEL_TOKEN,
|
||||
symbol: "SPAMTKN",
|
||||
name: "Spam Token",
|
||||
decimals: "6",
|
||||
holders_count: "50000",
|
||||
...token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
debugFetch.mockReset();
|
||||
});
|
||||
|
||||
test("a token with plenty of reported holders is listed", async () => {
|
||||
respondWith([item()]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBe(50000);
|
||||
});
|
||||
|
||||
test("a token reporting zero holders is still excluded", async () => {
|
||||
respondWith([item({ token: { holders_count: "0" } })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("an unknown holder count does not admit an unvouched token", async () => {
|
||||
respondWith([item({ token: { holders_count: null } })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
// The path that reaches the send selector and the history filter: a token
|
||||
// the user vouched for by tracking it is listed whatever the explorer
|
||||
// says, and it must carry the unknown count through as null, not as the
|
||||
// zero that would then hide it downstream.
|
||||
test("a tracked token with an unknown count is listed with holders null", async () => {
|
||||
respondWith([item({ token: { holders_count: undefined } })]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
|
||||
{ address: NOVEL_TOKEN.toUpperCase() },
|
||||
]);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("a known-list token with an unknown count is listed with holders null", async () => {
|
||||
respondWith([
|
||||
item({
|
||||
token: {
|
||||
address_hash: USDC_CONTRACT,
|
||||
symbol: "USDC",
|
||||
holders_count: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("no test in this file performed a network request", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// The shipped Content Security Policy, pinned in both directions.
|
||||
//
|
||||
// This is the anti-regression check for #182. libsodium decides its
|
||||
// backend by trying to compile WebAssembly and catching the failure, so a
|
||||
// CSP that refuses WASM demotes the vault to the wasm2js translation —
|
||||
// roughly 20x slower per Argon2id derivation — and says so only in a
|
||||
// console message nobody reads. Dropping 'wasm-unsafe-eval' from either
|
||||
// manifest therefore has to fail a check, not a log line.
|
||||
//
|
||||
// It is equally a check against loosening. 'wasm-unsafe-eval' is granted
|
||||
// deliberately and narrowly (see the backend note in src/shared/vault.js);
|
||||
// 'unsafe-eval', 'unsafe-inline' and any remote script source are not, and
|
||||
// an exact match on the token set is what keeps the next edit from
|
||||
// smuggling one in alongside.
|
||||
//
|
||||
// build.js copies these files to dist/<target>/manifest.json verbatim, so
|
||||
// what is asserted here is what ships.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
||||
|
||||
const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
|
||||
const EXPECTED_OBJECT_SRC = ["'self'"];
|
||||
|
||||
const FORBIDDEN_SOURCES = [
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"http:",
|
||||
"https:",
|
||||
"data:",
|
||||
"blob:",
|
||||
"*",
|
||||
];
|
||||
|
||||
function readManifest(name) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
// "script-src 'self'; object-src 'self'" -> { "script-src": ["'self'"], ... }
|
||||
function parseCsp(policy) {
|
||||
const directives = {};
|
||||
for (const part of policy.split(";")) {
|
||||
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) continue;
|
||||
directives[tokens[0]] = tokens.slice(1);
|
||||
}
|
||||
return directives;
|
||||
}
|
||||
|
||||
function assertPolicy(policy) {
|
||||
const directives = parseCsp(policy);
|
||||
expect(Object.keys(directives).sort()).toEqual([
|
||||
"object-src",
|
||||
"script-src",
|
||||
]);
|
||||
expect(directives["script-src"].slice().sort()).toEqual(
|
||||
EXPECTED_SCRIPT_SRC,
|
||||
);
|
||||
expect(directives["object-src"].slice().sort()).toEqual(
|
||||
EXPECTED_OBJECT_SRC,
|
||||
);
|
||||
for (const source of FORBIDDEN_SOURCES) {
|
||||
expect(directives["script-src"]).not.toContain(source);
|
||||
expect(directives["object-src"]).not.toContain(source);
|
||||
}
|
||||
}
|
||||
|
||||
describe("shipped Content Security Policy", () => {
|
||||
// MV3 takes an object and applies extension_pages to the popup and the
|
||||
// background service worker, which is where libsodium runs.
|
||||
test("chrome MV3 allows WASM and nothing else beyond 'self'", () => {
|
||||
const csp = readManifest("chrome").content_security_policy;
|
||||
expect(typeof csp).toBe("object");
|
||||
expect(Object.keys(csp)).toEqual(["extension_pages"]);
|
||||
assertPolicy(csp.extension_pages);
|
||||
});
|
||||
|
||||
// MV2 takes the policy as a bare string. Firefox does not require
|
||||
// 'wasm-unsafe-eval' for MV2 today — enforcement is report-only and
|
||||
// Bugzilla 1770909 is still open — so that token is future-proofing
|
||||
// for when it lands, not a mandate, and it stays inside Firefox's MV2
|
||||
// base-CSP ceiling. object-src 'self' is the load-bearing half: a
|
||||
// Firefox before 106 rejects an MV2 policy string that omits
|
||||
// object-src and falls back to its own default, discarding everything
|
||||
// declared here. Same policy as Chrome, different manifest shape.
|
||||
test("firefox MV2 allows WASM and nothing else beyond 'self'", () => {
|
||||
const csp = readManifest("firefox").content_security_policy;
|
||||
expect(typeof csp).toBe("string");
|
||||
assertPolicy(csp);
|
||||
});
|
||||
|
||||
// The two targets share one codebase and one crypto path; a policy
|
||||
// that drifts apart between them means one of the two builds is
|
||||
// running a backend nothing tests.
|
||||
test("both targets ship the same policy", () => {
|
||||
const chrome =
|
||||
readManifest("chrome").content_security_policy.extension_pages;
|
||||
const firefox = readManifest("firefox").content_security_policy;
|
||||
expect(firefox).toBe(chrome);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
// Tests for the token filtering in the Send view's token selector
|
||||
// (src/popup/views/send.js).
|
||||
//
|
||||
// The selector decides which of the user's tokens can be spent at all, so
|
||||
// over-filtering here is worse than in the history list: the asset is not
|
||||
// merely hidden, it becomes unspendable through the UI. Issue #230: an
|
||||
// explorer that omits holders_count was read as "zero holders" and the token
|
||||
// disappeared from this list.
|
||||
//
|
||||
// renderSendTokenSelect only ever touches getElementById, createElement,
|
||||
// innerHTML, value, textContent and appendChild, so a small stub document is
|
||||
// enough to drive it; the real DOM behaviour of the view is covered by
|
||||
// tests/e2e/run.js.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
||||
|
||||
const USDC_CONTRACT = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
let select;
|
||||
|
||||
function installStubDocument() {
|
||||
select = { innerHTML: "", children: [] };
|
||||
select.appendChild = (child) => select.children.push(child);
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === "send-token" ? select : null),
|
||||
createElement: () => ({ value: "", textContent: "" }),
|
||||
};
|
||||
}
|
||||
|
||||
// The symbols offered for sending, excluding the hardcoded ETH option that
|
||||
// renderSendTokenSelect writes straight into innerHTML.
|
||||
function offeredTokens() {
|
||||
return select.children.map((opt) => opt.value.toLowerCase());
|
||||
}
|
||||
|
||||
function tokenBalance(overrides) {
|
||||
return {
|
||||
address: NOVEL_TOKEN,
|
||||
symbol: "SPAMTKN",
|
||||
decimals: 18,
|
||||
balance: "12.5",
|
||||
holders: 50000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(tokenBalances) {
|
||||
installStubDocument();
|
||||
renderSendTokenSelect({ address: "0x" + "a".repeat(40), tokenBalances });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.fraudContracts = [];
|
||||
state.hideLowHolderTokens = true;
|
||||
});
|
||||
|
||||
describe("the low-holder rule in the send token selector", () => {
|
||||
test("ETH is always offered", () => {
|
||||
render([]);
|
||||
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a token with plenty of holders is offered", () => {
|
||||
render([tokenBalance()]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
test("a token reporting zero holders is withheld", () => {
|
||||
render([tokenBalance({ holders: 0 })]);
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
});
|
||||
|
||||
test("boundary: 999 holders is withheld, 1000 is offered", () => {
|
||||
render([tokenBalance({ holders: 999 })]);
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
render([tokenBalance({ holders: 1000 })]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
// Issue #230: an unknown holder count must not read as zero. A token the
|
||||
// user demonstrably holds — it has a balance — cannot be made unspendable
|
||||
// by a field the block explorer failed to report.
|
||||
test("a token whose holder count is unknown is still offered", () => {
|
||||
render([tokenBalance({ holders: null })]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
test("a token balance carrying no holders field at all is offered", () => {
|
||||
const t = tokenBalance();
|
||||
delete t.holders;
|
||||
render([t]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
test("the rule is bypassed entirely when the setting is off", () => {
|
||||
state.hideLowHolderTokens = false;
|
||||
render([tokenBalance({ holders: 0 })]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the other send-selector rules are unaffected", () => {
|
||||
test("a token spoofing a known symbol from a wrong address is withheld", () => {
|
||||
render([
|
||||
tokenBalance({ symbol: "USDC", holders: null }),
|
||||
tokenBalance({ address: USDC_CONTRACT, symbol: "USDC" }),
|
||||
]);
|
||||
expect(offeredTokens()).toEqual([USDC_CONTRACT.toLowerCase()]);
|
||||
});
|
||||
|
||||
test("a blocklisted fraud contract is withheld even with an unknown count", () => {
|
||||
state.fraudContracts = [NOVEL_TOKEN.toUpperCase()];
|
||||
render([tokenBalance({ holders: null })]);
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1473,65 +1473,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||
});
|
||||
|
||||
// Regression guards (#230): the explorer's holders_count is optional. A
|
||||
// missing field means the count is unknown; it does not mean the token
|
||||
// has no holders. Recording the two as the same number both hides a
|
||||
// legitimate token and makes the `holders !== null` guard in
|
||||
// filterTransactions unreachable for token transfers.
|
||||
describe("an unreported holders_count is unknown, not zero", () => {
|
||||
function spamTransferWithToken(token) {
|
||||
return [
|
||||
{
|
||||
transaction_hash: "0x" + "9".repeat(64),
|
||||
block_number: 21000070,
|
||||
timestamp: TS,
|
||||
from: { hash: ORDINARY_PEER },
|
||||
to: { hash: VICTIM },
|
||||
total: { value: "1500500000", decimals: "6" },
|
||||
token: token,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const OMITTED = {
|
||||
symbol: NOVEL_SPAM_SYMBOL,
|
||||
address_hash: NOVEL_SPAM_CONTRACT,
|
||||
};
|
||||
const NULLED = { ...OMITTED, holders_count: null };
|
||||
const ZERO = { ...OMITTED, holders_count: "0" };
|
||||
|
||||
test("an omitted holders_count parses to null", async () => {
|
||||
respondWith([], spamTransferWithToken(OMITTED));
|
||||
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);
|
||||
expect(txs[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("the transfer survives the low-holder filter", async () => {
|
||||
respondWith([], spamTransferWithToken(OMITTED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(filterTransactions(txs, filters()).transactions).toEqual(
|
||||
txs,
|
||||
);
|
||||
});
|
||||
|
||||
// The regression this fix could cause: a token that genuinely
|
||||
// reports zero holders must keep being filtered. Unlike the fake
|
||||
// "ETH" fixture above, this symbol is not in the token list, so the
|
||||
// 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);
|
||||
expect(txs[0].holders).toBe(0);
|
||||
expect(filterTransactions(txs, filters()).transactions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("failed responses yield an empty list rather than throwing", async () => {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: false,
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
const { parseEther } = require("ethers");
|
||||
const {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
FEE_KNOWN,
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
} = require("../src/shared/txValidation");
|
||||
|
||||
// A plausible mainnet fee: 21000 gas at 20 gwei.
|
||||
const FEE = 21000n * 20000000000n; // 0.00042 ETH
|
||||
|
||||
const GWEI = 1000000000n;
|
||||
const GAS_LIMIT = 21000n;
|
||||
|
||||
describe("toFixedPoint", () => {
|
||||
test("scales human decimals to 18 places", () => {
|
||||
expect(toFixedPoint("1.5")).toBe(parseEther("1.5"));
|
||||
expect(toFixedPoint("0")).toBe(0n);
|
||||
});
|
||||
|
||||
test("rejects values it cannot represent exactly", () => {
|
||||
expect(toFixedPoint("not a number")).toBe(null);
|
||||
expect(toFixedPoint("")).toBe(null);
|
||||
expect(toFixedPoint(null)).toBe(null);
|
||||
// More precision than 18 decimals can hold.
|
||||
expect(toFixedPoint("0.0000000000000000001")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransfer, native ETH", () => {
|
||||
const eth = (over) => ({
|
||||
isErc20: false,
|
||||
amount: "0.5",
|
||||
ethBalance: "1.0",
|
||||
feeStatus: FEE_KNOWN,
|
||||
feeWei: FEE,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("allows a send comfortably within balance", () => {
|
||||
const r = validateTransfer(eth());
|
||||
expect(r).toEqual({ canSend: true, codes: [] });
|
||||
});
|
||||
|
||||
test("blocks a send whose amount plus fee exceeds the balance", () => {
|
||||
// The whole balance: passes an amount-only check, fails once the fee
|
||||
// is counted. This is the bug this module exists to prevent.
|
||||
const r = validateTransfer(eth({ amount: "1.0", ethBalance: "1.0" }));
|
||||
expect(r.canSend).toBe(false);
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
|
||||
});
|
||||
|
||||
test("blocks a send left short by less than one fee", () => {
|
||||
const balance = "1.0";
|
||||
// One wei less headroom than the fee needs.
|
||||
const amount = "0.99958000000000001"; // 1.0 - 0.00042 + 1e-17
|
||||
const r = validateTransfer(eth({ amount, ethBalance: balance }));
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
|
||||
});
|
||||
|
||||
test("allows a send that leaves exactly the fee behind", () => {
|
||||
const r = validateTransfer(
|
||||
eth({ amount: "0.99958", ethBalance: "1.0" }),
|
||||
);
|
||||
expect(r).toEqual({ canSend: true, codes: [] });
|
||||
});
|
||||
|
||||
test("reports plain insufficient balance when the amount alone is too big", () => {
|
||||
const r = validateTransfer(eth({ amount: "2.0", ethBalance: "1.0" }));
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
|
||||
});
|
||||
|
||||
test("blocks while the fee estimate is still pending", () => {
|
||||
const r = validateTransfer(
|
||||
eth({ feeStatus: FEE_PENDING, feeWei: null }),
|
||||
);
|
||||
expect(r.canSend).toBe(false);
|
||||
expect(r.codes).toEqual([CODES.FEE_PENDING]);
|
||||
});
|
||||
|
||||
test("blocks when the fee estimate failed, without assuming zero", () => {
|
||||
const r = validateTransfer(
|
||||
eth({
|
||||
amount: "1.0",
|
||||
ethBalance: "1.0",
|
||||
feeStatus: FEE_UNAVAILABLE,
|
||||
feeWei: null,
|
||||
}),
|
||||
);
|
||||
expect(r.canSend).toBe(false);
|
||||
expect(r.codes).toEqual([CODES.FEE_UNAVAILABLE]);
|
||||
// A zero fee would have let this exact transfer through.
|
||||
expect(
|
||||
validateTransfer(
|
||||
eth({ amount: "1.0", ethBalance: "1.0", feeWei: 0n }),
|
||||
).canSend,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("still reports an over-balance amount before the estimate lands", () => {
|
||||
const r = validateTransfer(
|
||||
eth({
|
||||
amount: "2.0",
|
||||
ethBalance: "1.0",
|
||||
feeStatus: FEE_PENDING,
|
||||
feeWei: null,
|
||||
}),
|
||||
);
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH, CODES.FEE_PENDING]);
|
||||
});
|
||||
|
||||
test("rejects an amount it cannot do exact arithmetic on", () => {
|
||||
const r = validateTransfer(eth({ amount: "abc" }));
|
||||
expect(r.canSend).toBe(false);
|
||||
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
|
||||
});
|
||||
|
||||
test("rejects a negative amount", () => {
|
||||
// A negative amount parses to a perfectly good bigint, so neither
|
||||
// balance comparison can fire: both are trivially false against it.
|
||||
// Left unblocked it clears the screen and then dies at encode time.
|
||||
const r = validateTransfer(
|
||||
eth({ amount: "-1", ethBalance: "1.0", feeWei: 861000000000000n }),
|
||||
);
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
|
||||
expect(
|
||||
validateTransfer(eth({ amount: "-0.000000000000000001" })),
|
||||
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
|
||||
});
|
||||
|
||||
test("treats a missing balance as zero, not as unlimited", () => {
|
||||
const r = validateTransfer(eth({ ethBalance: undefined }));
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransfer, ERC-20", () => {
|
||||
const erc20 = (over) => ({
|
||||
isErc20: true,
|
||||
amount: "100.0",
|
||||
tokenBalance: "250.0",
|
||||
ethBalance: "1.0",
|
||||
feeStatus: FEE_KNOWN,
|
||||
feeWei: FEE,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("allows a transfer with tokens to spend and ETH for the fee", () => {
|
||||
expect(validateTransfer(erc20())).toEqual({ canSend: true, codes: [] });
|
||||
});
|
||||
|
||||
test("checks the token amount against the token balance", () => {
|
||||
const r = validateTransfer(erc20({ amount: "250.000001" }));
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
|
||||
});
|
||||
|
||||
test("does not charge the fee against the token balance", () => {
|
||||
// The full token balance is sendable: the fee is paid in ETH.
|
||||
expect(validateTransfer(erc20({ amount: "250.0" })).canSend).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks when the ETH balance does not cover the fee", () => {
|
||||
const r = validateTransfer(erc20({ ethBalance: "0.0001" }));
|
||||
expect(r.canSend).toBe(false);
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_FOR_FEE]);
|
||||
});
|
||||
|
||||
test("allows a fee exactly equal to the ETH balance", () => {
|
||||
const r = validateTransfer(erc20({ ethBalance: "0.00042" }));
|
||||
expect(r).toEqual({ canSend: true, codes: [] });
|
||||
});
|
||||
|
||||
test("reports both shortfalls when tokens and ETH are both short", () => {
|
||||
const r = validateTransfer(
|
||||
erc20({ amount: "300.0", ethBalance: "0.0" }),
|
||||
);
|
||||
expect(r.codes).toEqual([
|
||||
CODES.INSUFFICIENT_TOKEN,
|
||||
CODES.INSUFFICIENT_ETH_FOR_FEE,
|
||||
]);
|
||||
});
|
||||
|
||||
test("blocks while the fee estimate is pending or failed", () => {
|
||||
expect(
|
||||
validateTransfer(erc20({ feeStatus: FEE_PENDING, feeWei: null }))
|
||||
.codes,
|
||||
).toEqual([CODES.FEE_PENDING]);
|
||||
expect(
|
||||
validateTransfer(
|
||||
erc20({ feeStatus: FEE_UNAVAILABLE, feeWei: null }),
|
||||
).codes,
|
||||
).toEqual([CODES.FEE_UNAVAILABLE]);
|
||||
});
|
||||
|
||||
test("rejects a negative token amount", () => {
|
||||
const r = validateTransfer(
|
||||
erc20({ amount: "-0.5", feeWei: 861000000000000n }),
|
||||
);
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
|
||||
});
|
||||
|
||||
test("treats a missing token balance as zero", () => {
|
||||
const r = validateTransfer(erc20({ tokenBalance: undefined }));
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
|
||||
});
|
||||
});
|
||||
|
||||
// The reserve a node requires, not the fee the transaction is expected to
|
||||
// actually cost. An unpinned send goes out as type-2, and the node checks it
|
||||
// against maxFeePerGas; reserving gasPrice lets a transaction the node will
|
||||
// reject pass the gate.
|
||||
describe("feeReserveWei", () => {
|
||||
// baseFee 20 gwei, tip 1 gwei: eth_gasPrice reports ~21 gwei, while
|
||||
// ethers populates maxFeePerGas as baseFee * 2 + tip = 41 gwei.
|
||||
const type2 = {
|
||||
gasPrice: 21n * GWEI,
|
||||
maxFeePerGas: 41n * GWEI,
|
||||
maxPriorityFeePerGas: 1n * GWEI,
|
||||
};
|
||||
|
||||
test("reserves gasLimit * maxFeePerGas, not gasLimit * gasPrice", () => {
|
||||
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(GAS_LIMIT * 41n * GWEI);
|
||||
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
|
||||
// The number the node would not have accepted.
|
||||
expect(feeReserveWei(GAS_LIMIT, type2)).not.toBe(441000000000000n);
|
||||
});
|
||||
|
||||
test("gates out a send the type-2 reserve cannot fund", () => {
|
||||
// Exactly fundable against a gasPrice reserve (0.999559 + 0.000441 is
|
||||
// the whole balance to the wei), and short against the reserve the
|
||||
// node will actually require.
|
||||
const send = {
|
||||
isErc20: false,
|
||||
amount: "0.999559",
|
||||
ethBalance: "1.0",
|
||||
feeStatus: FEE_KNOWN,
|
||||
};
|
||||
expect(
|
||||
validateTransfer({
|
||||
...send,
|
||||
feeWei: GAS_LIMIT * type2.gasPrice,
|
||||
}).canSend,
|
||||
).toBe(true);
|
||||
const r = validateTransfer({
|
||||
...send,
|
||||
feeWei: feeReserveWei(GAS_LIMIT, type2),
|
||||
});
|
||||
expect(r.canSend).toBe(false);
|
||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
|
||||
});
|
||||
|
||||
test("falls back to gasPrice on a network with no type-2 pricing", () => {
|
||||
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
|
||||
expect(feeReserveWei(GAS_LIMIT, legacy)).toBe(GAS_LIMIT * 21n * GWEI);
|
||||
});
|
||||
|
||||
test("returns null when no usable price or gas limit is available", () => {
|
||||
expect(feeReserveWei(GAS_LIMIT, { gasPrice: null })).toBe(null);
|
||||
expect(feeReserveWei(GAS_LIMIT, {})).toBe(null);
|
||||
expect(feeReserveWei(GAS_LIMIT, null)).toBe(null);
|
||||
expect(feeReserveWei(21000, type2)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// The display counterpart of the reserve: what the transaction is expected to
|
||||
// cost. Shown alongside the reserve so the screen neither contradicts the gate
|
||||
// nor quotes the user roughly double what they will pay.
|
||||
describe("feeEstimateWei", () => {
|
||||
const type2 = {
|
||||
gasPrice: 21n * GWEI,
|
||||
maxFeePerGas: 41n * GWEI,
|
||||
maxPriorityFeePerGas: 1n * GWEI,
|
||||
};
|
||||
|
||||
test("estimates gasLimit * gasPrice, below the reserve", () => {
|
||||
expect(feeEstimateWei(GAS_LIMIT, type2)).toBe(441000000000000n);
|
||||
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
|
||||
expect(feeEstimateWei(GAS_LIMIT, type2)).toBeLessThan(
|
||||
feeReserveWei(GAS_LIMIT, type2),
|
||||
);
|
||||
});
|
||||
|
||||
test("equals the reserve when the network has no type-2 pricing", () => {
|
||||
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
|
||||
expect(feeEstimateWei(GAS_LIMIT, legacy)).toBe(
|
||||
feeReserveWei(GAS_LIMIT, legacy),
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to maxFeePerGas when there is no gasPrice", () => {
|
||||
const noLegacy = { gasPrice: null, maxFeePerGas: 41n * GWEI };
|
||||
expect(feeEstimateWei(GAS_LIMIT, noLegacy)).toBe(
|
||||
feeReserveWei(GAS_LIMIT, noLegacy),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null on the same unusable inputs as the reserve", () => {
|
||||
expect(feeEstimateWei(GAS_LIMIT, {})).toBe(null);
|
||||
expect(feeEstimateWei(GAS_LIMIT, null)).toBe(null);
|
||||
expect(feeEstimateWei(GAS_LIMIT, { gasPrice: -1n })).toBe(null);
|
||||
expect(feeEstimateWei(21000, type2)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
|
||||
// Each of these previously returned { canSend: true, codes: [] } — counting no
|
||||
// fee at all, on a full-balance send, in the direction that lets money out.
|
||||
describe("validateTransfer, unusable fee input fails closed", () => {
|
||||
const fullBalanceSend = (over) => ({
|
||||
isErc20: false,
|
||||
amount: "1.0",
|
||||
ethBalance: "1.0",
|
||||
...over,
|
||||
});
|
||||
|
||||
test("blocks a null fee claiming to be known", () => {
|
||||
const r = validateTransfer(
|
||||
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: null }),
|
||||
);
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
|
||||
});
|
||||
|
||||
test("blocks a known fee that is a number rather than a bigint", () => {
|
||||
const r = validateTransfer(
|
||||
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: 420000000000000 }),
|
||||
);
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
|
||||
});
|
||||
|
||||
test("blocks an unrecognised fee status", () => {
|
||||
const r = validateTransfer(fullBalanceSend({ feeStatus: "bogus" }));
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
|
||||
});
|
||||
|
||||
test("blocks a negative fee", () => {
|
||||
const r = validateTransfer(
|
||||
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: -1n }),
|
||||
);
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
|
||||
});
|
||||
|
||||
test("blocks an ERC-20 transfer on an unusable fee too", () => {
|
||||
const r = validateTransfer({
|
||||
isErc20: true,
|
||||
amount: "100.0",
|
||||
tokenBalance: "250.0",
|
||||
ethBalance: "1.0",
|
||||
feeStatus: FEE_KNOWN,
|
||||
feeWei: null,
|
||||
});
|
||||
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// The unit tests must exercise the libsodium backend that actually ships
|
||||
// (#182). Before this, they could not: node compiles WebAssembly happily,
|
||||
// the extension CSP refused it, and so the browser silently ran the
|
||||
// wasm2js translation while every test ran the WASM build.
|
||||
//
|
||||
// With 'wasm-unsafe-eval' in both manifests the two agree, and these tests
|
||||
// hold that agreement in place from the node side. tests/manifest.test.js
|
||||
// holds up the CSP end of it, and the end-to-end suite observes the real
|
||||
// popup.
|
||||
|
||||
const { cryptoBackend } = require("../src/shared/vault");
|
||||
|
||||
// The module libsodium-wrappers-sumo itself requires and drives. Not a new
|
||||
// dependency: it is inspected here, never used to perform crypto, because
|
||||
// it is the only thing that can say which backend is loaded.
|
||||
const SODIUM_CORE = "libsodium-sumo";
|
||||
|
||||
describe("libsodium backend", () => {
|
||||
test("this realm compiles WebAssembly, so the tests run the WASM build", async () => {
|
||||
await expect(cryptoBackend()).resolves.toBe("wasm");
|
||||
});
|
||||
|
||||
test("libsodium did not swap in the wasm2js fallback", async () => {
|
||||
const core = require(SODIUM_CORE);
|
||||
await require("libsodium-wrappers-sumo").ready;
|
||||
// useBackupModule is the entry point to the fallback; taking it
|
||||
// replaces the module's exports with the translation's, and the
|
||||
// entry point goes with them. Still present after ready means the
|
||||
// WASM module is the one in place. The test below is what keeps
|
||||
// that inference honest.
|
||||
expect(typeof core.useBackupModule).toBe("function");
|
||||
});
|
||||
|
||||
// Deliberately last, and deliberately destructive: it takes the
|
||||
// fallback, which replaces the loaded module for the rest of this
|
||||
// file. Jest gives each test file its own module registry, so nothing
|
||||
// outside sees it.
|
||||
//
|
||||
// Without this, the check above would be a claim about libsodium's
|
||||
// internals with nothing holding it to account: if a future version
|
||||
// kept useBackupModule on the fallback module too, the marker would
|
||||
// quietly become true in both backends and the test would pass while
|
||||
// measuring nothing. Forcing the fallback and watching the marker
|
||||
// disappear is what makes its presence mean something.
|
||||
test("the fallback marker distinguishes the two backends", async () => {
|
||||
const core = require(SODIUM_CORE);
|
||||
await require("libsodium-wrappers-sumo").ready;
|
||||
expect(typeof core.useBackupModule).toBe("function");
|
||||
await core.useBackupModule();
|
||||
expect(typeof core.useBackupModule).toBe("undefined");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user