Compare commits

..

4 Commits

Author SHA1 Message Date
e32d6896f2 fix: WaitTx timeout no longer overwrites a rendered success screen (closes #155)
All checks were successful
check / check (push) Successful in 34s
A poll tick that found a receipt called showSuccess() and then fell through
to the elapsed check, so on the tick crossing the 60-second deadline the
"Transaction Confirmed" screen was immediately replaced by "not confirmed
within 60 seconds" — the user is told a confirmed transaction failed.

The wait now has an explicit lifecycle. A wait id is bumped by endWait(),
which is called on receipt, on timeout, when a new wait starts and when the
user navigates away; every timer callback and every post-await continuation
checks it, so exactly one outcome can be rendered per wait and no stale
timer or in-flight receipt lookup can touch a view it no longer owns.

A receipt lookup that throws is treated as "no answer this tick" rather than
"no receipt": the poll returns before the deadline check and keeps running,
so one transient RPC failure cannot declare a timeout. This matters most on
a resumed wait, whose first poll is immediate and may already be past the
deadline, where a single error would otherwise be terminal. Retrying is
bounded: six consecutive failed lookups — 60 seconds at the poll cadence,
the same patience the confirmation deadline gets — end the wait and report
that the network could not be reached, pointing at the RPC URL in Settings.
That is a different fact from the timeout, because the chain was never
asked, and it says so rather than claiming the transaction did not confirm.
Any lookup that answers, with a receipt or with null, resets the count. An
unbounded retry would be worse than the bug it avoids: the wait is persisted,
so a mistyped RPC URL would leave a wait that every popup open resumes and
nothing ever ends, on a view with no exit control of its own.

The wait is also persisted (state.viewData.pendingWait) and "wait-tx" is now
restorable: reopening the popup resumes the poll with the elapsed counter
and the deadline still measured from the original broadcast, instead of
silently abandoning the wait. restoreWait() validates every field startWait()
goes on to use, not just the presence of the containers — hash, a non-array
object txInfo carrying a string to and a string amount, and a finite numeric
broadcastTime — and returns false otherwise. txInfo.to reaches addressTitle(),
which calls address.toLowerCase(), so a payload merely missing that one field
would throw a TypeError out of restoreView(), which init() does not guard:
the rest of popup init is skipped and wait-tx stays on screen with no back
control. A non-numeric broadcastTime leaves an unexitable wait counting
"NaNs". Polling stays in the popup rather than moving to the background,
which would depend on setInterval surviving in an MV3 service worker.

"wait-tx" is added to src/popup/restorableViews.js, and a test pins its
membership. restoreView() refuses any view outside that set, so dropping the
entry would kill the resume feature silently — the other tests call
restoreWait() directly and never read the set.

The 60-second threshold and the timeout copy are unchanged.
2026-08-12 08:37:32 +00:00
ce4a0d7b8d fix: distinguish an unknown holder count from zero so a legitimate token is not filtered (closes #230)
All checks were successful
check / check (push) Successful in 39s
2026-08-12 10:34:45 +02:00
bf1dbec87c fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 26s
2026-08-12 10:30:15 +02:00
ba35282092 docs: describe the bundled token list by its criterion, not a drifting count (closes #239)
All checks were successful
check / check (push) Successful in 29s
2026-08-12 10:20:40 +02:00
18 changed files with 800 additions and 67 deletions

107
README.md
View File

@@ -123,16 +123,17 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright `playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image. version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen, the It covers popup load, WebAssembly compilation under the shipped CSP (see
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen [Content Security Policy](#content-security-policy)), wallet creation through
— which wallet types are offered it, that it holds nothing before the password the UI, the Add Token screen, the transaction detail screen for an ERC-20
is accepted, that a wrong password reveals nothing, that leaving it by either transfer, and the recovery phrase screen — which wallet types are offered it,
route wipes it — including a leave taken while the decrypt is still running — that it holds nothing before the password is accepted, that a wrong password
and that reopening the popup does not land on it. All outbound network is reveals nothing, that leaving it by either route wipes it — including a leave
intercepted at the browser level and served from fixtures in taken while the decrypt is still running — and that reopening the popup does not
`tests/e2e/network.js`, so the run is deterministic and fully offline; land on it. All outbound network is intercepted at the browser level and served
unrecognised outbound requests are reported as failures rather than silently from fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
allowed. offline; unrecognised outbound requests are reported as failures rather than
silently allowed.
That reporting has one bound worth knowing. Observation ends when the browser 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 context is torn down, and nothing can watch traffic after that, so the run keeps
@@ -443,9 +444,9 @@ The core hierarchy is **Wallets → Addresses**:
Which tokens an address shows is decided by `fetchTokenBalances()` in Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so `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 tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled top-250 token list, is tracked by balance is nonzero and it is in the bundled known-token list, is tracked by the
the user, or has 1,000 or more holders; a token claiming a symbol from the user, or has 1,000 or more holders; a token claiming a symbol from the bundled
bundled list from any other contract address is always dropped. That filter is list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list. 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 Tracked tokens with a zero balance are listed as well while "Show tracked tokens
@@ -1021,7 +1022,7 @@ communicates with three external services to function as a wallet:
What the extension does NOT do: What the extension does NOT do:
- No analytics or telemetry services - No analytics or telemetry services
- No token list APIs (the top-250 token list is bundled at build time) - No token list APIs (the known-token list is bundled at build time)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
@@ -1081,6 +1082,36 @@ battle-tested.
Exceptions require explicit authorization in a code comment referencing this Exceptions require explicit authorization in a code comment referencing this
policy, but as of now there are none. 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 ### DEBUG Mode Policy
The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and
@@ -1149,8 +1180,8 @@ hardcoded test phrase.
- Add multiple addresses within an HD wallet - Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously - Manage multiple wallets simultaneously
- View ETH balance per address - View ETH balance per address
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more - View ERC-20 token balances (tokens on the bundled known-token list, tokens
holders, and tokens the user adds by contract address) with 1,000 or more holders, and tokens the user adds by contract address)
- Send ETH to an address - Send ETH to an address
- Send ERC-20 tokens to an address - Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code) - Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -1208,26 +1239,30 @@ indexes it as a real token transfer.
address. Users should always verify the full address on the confirmation address. Users should always verify the full address on the confirmation
screen before signing or sending. screen before signing or sending.
- **Known token symbol verification**: AutistMask ships a hardcoded list of the - **Known token symbol verification**: AutistMask ships a hardcoded list of
top 250 ERC-20 tokens with their legitimate contract addresses and symbols. high-market-cap ERC-20 tokens with their legitimate contract addresses and
Any token transfer claiming a symbol from this list (e.g. "ETH", "USDT", symbols. The list is a point-in-time snapshot of the highest-market-cap
"USDC") but originating from an unrecognized contract address is identified as Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified
a spoof and filtered from display. The fake "Ethereum" token in the attack on-chain and addresses EIP-55 checksummed; `TOKENS` in
above used symbol "ETH" from contract `src/shared/tokenList.js` is the authoritative set. It is bundled at build
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known time and only changes when that file is regenerated. Any token transfer
WETH contract — so it would be caught by this check. Detecting a spoof is also claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
what adds a contract to the fraud contract blocklist below; that is the only from an unrecognized contract address is identified as a spoof and filtered
thing that populates it. In the transaction history the check is the "Hide from display. The fake "Ethereum" token in the attack above used symbol "ETH"
fake tokens impersonating a known symbol" setting, on by default; with it off, from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
spoofed transfers are shown and no new blocklist entries are learned from match the known WETH contract — so it would be caught by this check. Detecting
them. The send-screen token selector applies the same check unconditionally, a spoof is also what adds a contract to the fraud contract blocklist below;
because it decides which tokens the user can act on rather than what the that is the only thing that populates it. In the transaction history the check
history displays. The balance list applies it unconditionally too, but not is the "Hide fake tokens impersonating a known symbol" setting, on by default;
identically: it exempts symbols that `KNOWN_SYMBOLS` maps to `null`, and with it off, spoofed transfers are shown and no new blocklist entries are
`"ETH"` is the only one. So the fake "Ethereum" token above is filtered from learned from them. The send-screen token selector applies the same check
the transaction history and from the send selector, but a fake-`ETH` ERC-20 unconditionally, because it decides which tokens the user can act on rather
that clears the balance list's own 1,000-holder floor — or that the user than what the history displays. The balance list applies it unconditionally
tracked manually — is still shown in the balance list. 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 - **Low-holder token filtering**: Token transfers from ERC-20 contracts with
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.

16
TODO.md
View File

@@ -50,6 +50,22 @@ undefined identifiers, which is how
end the wait, reported as an unreachable network rather than as a timeout), end the wait, reported as an unreachable network rather than as a timeout),
and the wait now resumes after a popup close and the wait now resumes after a popup close
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)). ([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
- 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 - 2026-08-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history (`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds filter and the fraud-contract learning it feeds

View File

@@ -327,17 +327,19 @@ individually removed to reset their permissions.
AutistMask includes several defenses against common Ethereum scams, all enabled AutistMask includes several defenses against common Ethereum scams, all enabled
by default: by default:
**Known token symbol verification.** AutistMask ships a list of roughly 500 **Known token symbol verification.** AutistMask ships a bundled list of
legitimate ERC-20 tokens with their contract addresses. If a transaction or high-market-cap ERC-20 tokens with their legitimate contract addresses — a
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
an unrecognized contract, it is identified as a spoof and hidden. In your at build time and updated only when a new release ships a newer snapshot. If a
transaction history this is the "Hide fake tokens impersonating a known symbol" transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
setting, which you can switch off; doing so also stops new entries being added but comes from an unrecognized contract, it is identified as a spoof and hidden.
to the fraud contract blocklist below, since detecting a spoof is what fills it. In your transaction history this is the "Hide fake tokens impersonating a known
The send token list always applies the check. Your balances apply it too, with symbol" setting, which you can switch off; doing so also stops new entries being
one exception: a token claiming the symbol "ETH" is not filtered there, so a added to the fraud contract blocklist below, since detecting a spoof is what
fake "ETH" token can still show up in your balance list even though it is hidden fills it. The send token list always applies the check. Your balances apply it
from your transaction history and from the send token list. 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 **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 from transaction history and the send token list, and are left out of your

View File

@@ -5,6 +5,9 @@
"description": "Minimal Ethereum wallet for Chrome", "description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"action": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -4,6 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"browser_action": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -13,6 +13,7 @@ const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList"); const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { getAddress } = require("ethers"); const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -132,7 +133,10 @@ function renderSendTokenSelect(addr) {
for (const t of addr.tokenBalances || []) { for (const t of addr.tokenBalances || []) {
if (isSpoofedToken(t)) continue; if (isSpoofedToken(t)) continue;
if (fraudSet.has(t.address.toLowerCase())) continue; if (fraudSet.has(t.address.toLowerCase())) continue;
if (state.hideLowHolderTokens && (t.holders || 0) < 1000) 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;
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = t.address; opt.value = t.address;
opt.textContent = t.symbol; opt.textContent = t.symbol;

View File

@@ -12,6 +12,7 @@ const { ERC20_ABI } = require("./constants");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); const { deriveAddressFromXpub } = require("./wallet");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); 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 // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -70,10 +71,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
if (bal === "0.0") continue; if (bal === "0.0") continue;
const tokenAddr = (item.token.address_hash || "").toLowerCase(); const tokenAddr = (item.token.address_hash || "").toLowerCase();
const holders = parseInt(item.token.holders_count || "0", 10); // null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
// the whole balance list, so it stays strict and admits a token
// only on a reported count — an unreported one is no evidence.
// A legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr); const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= 1000; const hasEnoughHolders =
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see // Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue; if (!isKnown && !isTracked && !hasEnoughHolders) continue;
@@ -278,6 +289,7 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
} }
module.exports = { module.exports = {
fetchTokenBalances,
refreshBalances, refreshBalances,
lookupTokenInfo, lookupTokenInfo,
getProvider, getProvider,

32
src/shared/holders.js Normal file
View File

@@ -0,0 +1,32 @@
// 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,
};

View File

@@ -9,6 +9,7 @@
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); 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 // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // over the address, not part of its identity. Every address comparison in
@@ -116,7 +117,10 @@ function parseTokenTransfer(tt, addrLower) {
contractAddress: normalizeAddress( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "", tt.token?.address_hash || tt.token?.address || "",
), ),
holders: parseInt(tt.token?.holders_count || "0", 10), // 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),
}; };
} }
@@ -292,12 +296,13 @@ function filterTransactions(txs, filters = {}) {
continue; continue;
} }
// Filter low-holder tokens (<1000) if setting is on // 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".
if ( if (
filters.hideLowHolderTokens && filters.hideLowHolderTokens &&
tx.contractAddress && tx.contractAddress &&
tx.holders !== null && isLowHolderCount(tx.holders)
tx.holders < 1000
) { ) {
continue; continue;
} }

View File

@@ -1,14 +1,80 @@
// Vault: password-based encryption of secrets using libsodium. // Vault: password-based encryption of secrets using libsodium.
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption. // Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
// All crypto operations are delegated to libsodium — no raw primitives. // 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 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; let ready = false;
async function ensureReady() { async function ensureReady() {
if (!ready) { if (!ready) {
await sodium.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; ready = true;
} }
} }
@@ -59,4 +125,4 @@ async function decryptWithPassword(encrypted, password) {
return sodium.to_string(plaintext); return sodium.to_string(plaintext);
} }
module.exports = { encryptWithPassword, decryptWithPassword }; module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };

View File

@@ -22,18 +22,12 @@ const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
// entry must name the issue that will remove it. This list is the one // 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 // concession in an otherwise zero-tolerance policy: an uncaught error is
// how this harness caught issue #150 in the first place. // how this harness caught issue #150 in the first place.
const ALLOWED_ERRORS = [ //
{ // Empty, and worth keeping that way. Its only entry was the WASM
// libsodium ships a WASM build and an asm.js fallback. The // CompileError libsodium provoked on every popup load, deleted with #182
// extension CSP (script-src 'self', with no wasm-unsafe-eval) // when both manifests started allowing WASM; the run that used to need it
// refuses the WASM module on every popup load; libsodium catches // is now the run that proves the fix.
// it and falls back to asm.js, so the wallet works. Deciding const ALLOWED_ERRORS = [];
// 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) { function isAllowed(text) {
return ALLOWED_ERRORS.some((a) => a.pattern.test(text)); return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
@@ -247,6 +241,26 @@ async function visible(page, selector, timeout = 15000) {
await page.waitForSelector(selector, { state: "visible", timeout }); 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) { async function openPopup(ctx, popupUrl) {
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(popupUrl); await page.goto(popupUrl);
@@ -293,5 +307,6 @@ module.exports = {
launch, launch,
openAddressDetail, openAddressDetail,
openPopup, openPopup,
pageCompilesWasm,
visible, visible,
}; };

View File

@@ -15,6 +15,7 @@ const {
launch, launch,
openAddressDetail, openAddressDetail,
openPopup, openPopup,
pageCompilesWasm,
visible, visible,
} = require("./harness"); } = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
@@ -60,6 +61,27 @@ test("popup loads and reaches the welcome view", async (env) => {
assert(title === "AutistMask", "unexpected popup title: " + title); 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) => { test("wallet creation through the UI reaches the main view", async (env) => {
env.phrase = await createWallet(env.page); env.phrase = await createWallet(env.page);
assert( assert(

166
tests/holders.test.js Normal file
View File

@@ -0,0 +1,166 @@
// 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();
});
});

105
tests/manifest.test.js Normal file
View File

@@ -0,0 +1,105 @@
// 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);
});
});

View File

@@ -0,0 +1,123 @@
// 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([]);
});
});

View File

@@ -1473,6 +1473,65 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); 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 () => { test("failed responses yield an empty list rather than throwing", async () => {
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: false, ok: false,

View File

@@ -339,6 +339,21 @@ describe("WaitTx persistence across popup close", () => {
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() }, { hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() }, { hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() }, { hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
// Otherwise complete but for a non-string `to`: only the `to`
// check rejects this one, and without it addressTitle() throws
// out of restoreView().
{
hash: TX_HASH,
txInfo: { to: 42, amount: "0.0050" },
broadcastTime: Date.now(),
},
// Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present.
{
hash: TX_HASH,
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
broadcastTime: Date.now(),
},
{ {
hash: TX_HASH, hash: TX_HASH,
txInfo: { to: RECIPIENT }, txInfo: { to: RECIPIENT },

View File

@@ -0,0 +1,52 @@
// 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");
});
});