Compare commits

...

5 Commits

Author SHA1 Message Date
8f908ce5b2 fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 30s
libsodium ships a WASM build and a wasm2js translation in one file, tries
WASM first, and silently falls back 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 vault's own Argon2id parameters (OPSLIMIT_INTERACTIVE,
MEMLIMIT_INTERACTIVE), node 22: WASM 141-198ms per derivation, wasm2js
3204-3660ms. The work factor is identical either way — it is set by the
ops and memory parameters, not by wall time — so the fallback bought no
security and cost about 3.5s on every operation that asks for the
password, which is every signature.

Both manifests now declare script-src 'self' 'wasm-unsafe-eval';
object-src 'self' for extension pages: an object under
content_security_policy.extension_pages for Chrome MV3, a bare string for
Firefox MV2. The keyword permits compiling WebAssembly and nothing else —
not eval() of strings, not inline script, not remote script — and reaching
it requires already executing script in an extension page. 'unsafe-eval'
is not granted.

The silence is what made this dangerous, so the fallback is now loud at
three levels: tests/manifest.test.js pins both policies to exactly that
token set, failing make check if the grant is dropped or if anything is
added beside it; tests/vaultBackend.test.js asserts the unit tests
exercise the WASM backend, with a self-validating check that libsodium
never swapped its fallback in; and the e2e suite compiles a WebAssembly
module inside the real popup under the real manifest, with the harness
allowlist entry that used to excuse the CompileError now deleted.

The runtime fallback itself is kept — a wallet that refuses to decrypt is
worse than a slow one — but vault.js now reports the backend and logs an
error when it is not WASM.
2026-08-11 13:03:17 +00:00
86cdea5e4e chore: repo policy compliance sweep — test rerun, frozen lockfile, documented targets (closes #166)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:57:51 +02:00
f271bcd7b4 fix: one transaction history row per value movement (closes #177)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:56:30 +02:00
93e3f6e4e2 fix: correct verify-build diagnostics and close two robustness gaps (closes #180)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:55:06 +02:00
9b957ffd69 fix: derive hasWallet from the wallet list on load (closes #195)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:51:22 +02:00
19 changed files with 1015 additions and 95 deletions

View File

@@ -1,3 +1,6 @@
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
# build-info stamping and the Dockerfile runs `make build`, so excluding it
# would make every built extension report commitHash "unknown".
node_modules node_modules
.DS_Store .DS_Store
dist dist

View File

@@ -11,7 +11,7 @@ setup:
@script/setup @script/setup
install: install:
@yarn install @yarn install --frozen-lockfile
test: test:
@script/test @script/test

View File

@@ -31,10 +31,13 @@ list exists to detect symbol spoofing attacks and improve UX.
```bash ```bash
git clone https://git.eeqj.de/sneak/autistmask.git git clone https://git.eeqj.de/sneak/autistmask.git
cd autistmask cd autistmask
make install make setup
make build make build
``` ```
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
the lockfile and installs the git pre-commit hook.
Load the extension: Load the extension:
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click - **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
@@ -97,6 +100,19 @@ provide:
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook
The Makefile shims to those. It also carries a few targets that have no
`script/` counterpart and are Makefile-only conveniences:
- `make install``yarn install --frozen-lockfile` on its own, without the rest
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/`
- `make dev` — build in watch mode
## End-to-End Tests ## End-to-End Tests
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real `make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -107,12 +123,13 @@ 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 and It covers popup load, WebAssembly compilation under the shipped CSP (see
the transaction detail screen for an ERC-20 transfer. All outbound network is [Content Security Policy](#content-security-policy)), wallet creation through
intercepted at the browser level and served from fixtures in the UI, the Add Token screen and the transaction detail screen for an ERC-20
`tests/e2e/network.js`, so the run is deterministic and fully offline; transfer. 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
@@ -931,6 +948,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

23
TODO.md
View File

@@ -44,6 +44,21 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 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: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
message now states what is and is not proven, an unreadable bundle is
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist - 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)). ([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
@@ -53,10 +68,18 @@ undefined identifiers, which is how
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names, - 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)). sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the - 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)). ([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
a plain ERC-20 transfer absorbed into its token row
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model - 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)). ([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).

View File

@@ -29,6 +29,12 @@ function repoRelative(p) {
// reports every input that contributed to an output in the metafile, which is // reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike // the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification. // searching the minified text, it does not depend on what survived minification.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build searches every file and symlink under dist/ for a
// marker, without filtering by extension, and hard-fails if it cannot walk the
// whole tree, so a bundle emitted under some other extension fails there as
// unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) { function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs) return Object.entries(metafile.outputs)
.filter(([outFile, info]) => { .filter(([outFile, info]) => {
@@ -115,8 +121,17 @@ async function build() {
// build that never gets around to writing one cannot be verified against // build that never gets around to writing one cannot be verified against
// a stale list. // a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true }); fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync( execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );

View File

@@ -5,6 +5,9 @@
"description": "Minimal Ethereum wallet for Chrome", "description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab"], "permissions": ["storage", "activeTab"],
"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", "<all_urls>"], "permissions": ["storage", "activeTab", "<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

@@ -7,6 +7,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest --forceExit", "test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "prettier --check .", "lint": "prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",

View File

@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Running tests..." echo "Running tests..."
timeout 30 yarn run test 2>&1 timeout 30 yarn run test 2>&1 || {
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
} }
main "$@" main "$@"

View File

@@ -34,16 +34,51 @@ fail() {
exit 1 exit 1
} }
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "no marker" — that would blame the bundle for a permissions or I/O fault.
has_marker() { has_marker() {
grep -q -F "$1" "$2" 2>/dev/null _hm_status=0
grep -q -F -e "$1" -- "$2" || _hm_status=$?
case "$_hm_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_hm_status reading $2, so the file could not be
searched and its DEBUG state was not checked at all. That is a permissions
or I/O fault on the artifact, not a change in the emitted output. Refusing
to report success."
;;
esac
}
# Does the manifest list the path $1, as a whole line? Same discipline as
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# manifest could not be read and is not an answer at all. Without this, an
# unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one.
is_listed() {
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_il_status reading $MANIFEST, so it could not be
searched and nothing was established about which bundles it lists. That is
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
to report success."
;;
esac
} }
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be # Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is # present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js: # what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time and the debug branch is live again. # DEBUG stops being known at build time. Neither means we are reading output
# Neither means we are reading output we do not understand. Both are hard # we do not understand. Both are hard failures; neither is ever treated as
# failures; neither is ever treated as absence of a problem. # absence of a problem.
read_marker() { read_marker() {
_file="$1" _file="$1"
_on=no _on=no
@@ -52,9 +87,14 @@ read_marker() {
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
if [ "$_on" = yes ] && [ "$_off" = yes ]; then if [ "$_on" = yes ] && [ "$_off" = yes ]; then
fail "$_file carries both debug markers, so the build-time DEBUG value fail "$_file carries both debug markers, so DEBUG was not resolved at
was never resolved and the debug branch is still live. Check that build.js build time: the ternary in src/shared/constants.js survived into the
still defines __BUILD_DEBUG__." emitted output. This does not mean the debug branch is live in this
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
context, so DEBUG evaluates to false at runtime. It does mean the
release/debug distinction is no longer enforced at build time, and which
way that fallback happens to evaluate is then an accident a refactor can
flip. Check that build.js still defines __BUILD_DEBUG__."
fi fi
if [ "$_on" = no ] && [ "$_off" = no ]; then if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be fail "$_file carries no debug marker, so its DEBUG state cannot be
@@ -70,13 +110,43 @@ read_marker() {
} }
# The manifest says which bundles must carry a marker. This says no other # The manifest says which bundles must carry a marker. This says no other
# emitted bundle may carry one, which catches a manifest that has gone stale # emitted file may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list. # or short rather than trusting whatever it happens to list.
#
# Deliberately unfiltered by extension. build.js selects manifest entries with
# an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every file under dist/ is searched, so build.js's filter is the only
# place the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive, so two things are enforced
# here rather than assumed:
#
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline
# ending in sort, so the sort is a separate step.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
check_unlisted_bundles() { check_unlisted_bundles() {
_listing="$(find dist -type f -name '*.js' | sort)" _find_status=0
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
while read -r _file; do while read -r _file; do
[ -n "$_file" ] || continue [ -n "$_file" ] || continue
if grep -q -x -F "$_file" "$MANIFEST"; then if is_listed "$_file"; then
continue continue
fi fi
if has_marker "$MARKER_ON" "$_file" || if has_marker "$MARKER_ON" "$_file" ||
@@ -113,12 +183,18 @@ main() {
fail "$MANIFEST is empty, so no emitted bundle was found to contain fail "$MANIFEST is empty, so no emitted bundle was found to contain
src/shared/constants.js. That is never correct, so it is a failure and not src/shared/constants.js. That is never correct, so it is a failure and not
a pass." a pass."
[ -r "$MANIFEST" ] ||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass."
count=0 count=0
while read -r file; do while read -r file; do
[ -n "$file" ] || continue [ -n "$file" ] || continue
[ -f "$file" ] || [ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist." fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file" read_marker "$file"
[ "$MARKER" = "$expected" ] || [ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected." fail "$file is $MARKER but this build expects $expected."

View File

@@ -84,8 +84,11 @@ async function loadState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || []; state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId; state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;

View File

@@ -113,6 +113,85 @@ function parseTokenTransfer(tt, addrLower) {
}; };
} }
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase(); const addrLower = address.toLowerCase();
@@ -145,53 +224,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {}; const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
const txsByHash = new Map(); const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count); const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length); log.debugf("fetchRecentTransactions done, count:", result.length);
return result; return result;
@@ -265,4 +302,8 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud }; return { transactions: filtered, newFraudContracts: newFraud };
} }
module.exports = { fetchRecentTransactions, filterTransactions }; module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};

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);
@@ -285,5 +299,6 @@ module.exports = {
launch, launch,
openAddressDetail, openAddressDetail,
openPopup, openPopup,
pageCompilesWasm,
visible, visible,
}; };

View File

@@ -14,6 +14,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");
@@ -55,6 +56,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) => {
await createWallet(env.page); await createWallet(env.page);
const addrCount = await env.page const addrCount = await env.page

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

@@ -0,0 +1,108 @@
// 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 this is future-proofing, not a
// mandate. It does not weaken anything under either baseline: Gecko's
// real MV2 default (extensions.webextensions.default-content-security-
// policy) is `script-src 'self' 'wasm-unsafe-eval';` with no object-src
// at all, so this string leaves script-src unchanged and ADDS
// object-src 'self', constraining <object>/<embed> sources that were
// previously unrestricted. Against MDN's documented MV2 default
// (`script-src 'self'; object-src 'self';`) it is a one-token loosening,
// identical to Chrome. Same policy, 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);
});
});

104
tests/state.test.js Normal file
View File

@@ -0,0 +1,104 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {
delete global.chrome;
});
describe("loadState hasWallet reconciliation", () => {
// A profile that deleted its last wallet on a build predating the write
// path fix keeps hasWallet: true forever. It must load as no wallet, which
// is what sends the popup to the welcome view.
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true });
await mod.loadState();
expect(mod.state.wallets).toEqual([]);
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
});
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("absent hasWallet with wallets present loads as having a wallet", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("consistent stored states are preserved", async () => {
const withWallet = loadModuleWith({
hasWallet: true,
wallets: oneWallet(),
});
await withWallet.mod.loadState();
expect(withWallet.mod.state.hasWallet).toBe(true);
const without = loadModuleWith({ hasWallet: false, wallets: [] });
await without.mod.loadState();
expect(without.mod.state.hasWallet).toBe(false);
});
test("empty storage leaves the default no-wallet state", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
expect(mod.state.wallets).toEqual([]);
});
// The correction is derived on every load rather than written back, so a
// load never has a storage side effect.
test("loadState does not write to storage", async () => {
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(set).not.toHaveBeenCalled();
});
// Deriving must not disturb the rest of the load.
test("other persisted fields still load", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
networkId: "sepolia",
theme: "dark",
activeAddress: ADDRESS,
});
await mod.loadState();
expect(mod.state.networkId).toBe("sepolia");
expect(mod.state.theme).toBe("dark");
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});

View File

@@ -36,6 +36,7 @@ global.chrome = { storage: { local: {} } };
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
mergeTransactions,
} = require("../src/shared/transactions"); } = require("../src/shared/transactions");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList"); const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
@@ -685,6 +686,339 @@ describe("legitimate transactions are never filtered", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes. One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------
// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "",
exactValue: "",
rawAmount: "",
rawUnit: "",
valueGwei: 0,
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
...overrides,
});
}
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
method: "transfer",
...overrides,
});
}
describe("mergeTransactions: one row per value movement", () => {
const HASH = "0x" + "d".repeat(64);
const OTHER_HASH = "0x" + "e".repeat(64);
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
test("a plain ERC-20 transfer yields one row, the token row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
});
test("an ETH-only transfer keeps its row unchanged", () => {
const merged = mergeTransactions([legitimateEthSend()], []);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual(legitimateEthSend());
});
test("a genuine zero-value native transaction is still displayed", () => {
const zero = nativeTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([zero], []);
expect(merged).toEqual([zero]);
});
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
const zero = erc20CallTx({ hash: HASH });
const unrelated = tokenTx({ hash: OTHER_HASH });
const merged = mergeTransactions([zero], [unrelated]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.hash).sort()).toEqual(
[HASH, OTHER_HASH].sort(),
);
});
test("a native transaction that moved ETH keeps its row beside the token row", () => {
// An undecoded call (no method name) carrying ETH that also emitted
// a token transfer: two real movements, so two rows.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
valueGwei: 250000000,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
});
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
// delete this row and lose a real ETH movement, so the decision is made
// on rawAmount as a BigInt.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.0000",
exactValue: "0.0000000005",
rawAmount: "500000000",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
"500000000",
);
});
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const sentLeg = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const receivedLeg = tokenTx({
hash: HASH,
from: ROUTER,
to: VICTIM,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("WETH");
expect(merged[0].exactValue).toBe("0.25");
// The user's own address and the contract called are preserved.
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(ROUTER);
expect(merged[0].directionLabel).toBe("Swap");
});
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const firstSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const secondSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [firstSent, secondSent]);
expect(merged).toHaveLength(1);
// With no received leg the display amount comes from the first sent
// leg, and a later sent leg does not overwrite it.
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
expect(merged[0].holders).toBe(3500000);
});
test("a contract call carrying ETH plus a token transfer stays one row", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "swapExactETHForTokens",
valueGwei: 250000000,
});
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [received]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
// The ETH leg is still visible as the row's native quantity.
expect(merged[0].valueGwei).toBe(250000000);
});
test("an approve keeps its row and survives the filters", () => {
const approve = contractCallTx({ hash: HASH });
const merged = mergeTransactions([approve], []);
expect(merged).toEqual([approve]);
expect(filterTransactions(merged, filters()).transactions).toEqual([
approve,
]);
});
test("a contract creation keeps its row", () => {
const creation = nativeTx({
hash: HASH,
from: VICTIM,
to: "",
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([creation], [])).toEqual([creation]);
});
test("a native self-send keeps its single row", () => {
const selfSend = nativeTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
});
test("a token self-send yields one row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(VICTIM);
});
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
const native = erc20CallTx({ hash: HASH });
const usdc = tokenTx({ hash: HASH });
const weth = tokenTx({
hash: HASH,
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [usdc, weth]);
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
test("rows are sorted by block number, newest first", () => {
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
const merged = mergeTransactions([older, newer], []);
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
});
test("the entries handed in are never mutated", () => {
const native = contractCallTx({ hash: HASH, method: "execute" });
const token = tokenTx({ hash: HASH });
const before = JSON.stringify([native, token]);
mergeTransactions([native], [token]);
expect(JSON.stringify([native, token])).toBe(before);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions // fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers. (The cross-address merge Home performs lives in // with ERC-20 transfers. (The cross-address merge Home performs lives in
@@ -886,13 +1220,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
}); });
// Documents current behaviour: for a plain ERC-20 transfer the method is // Regression guard for the duplicate-row bug: for a plain ERC-20
// "transfer", so parseTx does not mark the entry as a contract call in // transfer the method is "transfer", so parseTx does not mark the entry
// the display sense and the merge loop does not consolidate the token // as a contract call in the display sense. The native side of that
// transfer into it. The result is two entries for one transaction: a // transaction moved no ETH and is represented by the token row, so it
// zero-value native row and the real token row. The zero-value row also // must not survive the merge as a second, zero-value row.
// escapes dust filtering because isContractCall is true. test("a plain ERC-20 transfer produces exactly one entry", async () => {
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
const hash = "0x" + "5".repeat(64); const hash = "0x" + "5".repeat(64);
respondWith( respondWith(
[ [
@@ -925,14 +1258,15 @@ describe("fetchRecentTransactions merge and dedup", () => {
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(2); expect(txs).toHaveLength(1);
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]); expect(txs[0].symbol).toBe("USDC");
const nativeRow = txs.find((t) => t.symbol === "ETH"); expect(txs[0].exactValue).toBe("1.0");
expect(nativeRow.exactValue).toBe("0.0"); expect(txs[0].direction).toBe("sent");
expect(nativeRow.isContractCall).toBe(true); expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
// And the zero-value row is not removed by the dust filter. // The surviving row is the token row, and the filters keep it.
const kept = filterTransactions(txs, filters()).transactions; const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(2); expect(kept).toHaveLength(1);
expect(kept[0].symbol).toBe("USDC");
}); });
test("entries are sorted by block number descending and capped at count", async () => { test("entries are sorted by block number descending and capped at count", async () => {

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");
});
});