Compare commits

..

1 Commits

Author SHA1 Message Date
0a5779a120 fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 24s
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 12:42:55 +00:00
12 changed files with 76 additions and 693 deletions

View File

@@ -1,6 +1,3 @@
# .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 --frozen-lockfile @yarn install
test: test:
@script/test @script/test

View File

@@ -31,13 +31,10 @@ 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 setup make install
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
@@ -100,19 +97,6 @@ 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

18
TODO.md
View File

@@ -49,16 +49,6 @@ undefined identifiers, which is how
fallback at 20x the Argon2id cost, pinned in both directions by 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 `tests/manifest.test.js` and observed in the real popup by the e2e suite
([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)). ([#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)).
@@ -68,18 +58,10 @@ 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,12 +29,6 @@ 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]) => {
@@ -121,17 +115,8 @@ 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(
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, `npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );

View File

@@ -7,7 +7,6 @@
"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,13 +7,7 @@ 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,51 +34,16 @@ 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() {
_hm_status=0 grep -q -F "$1" "$2" 2>/dev/null
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. Neither means we are reading output # DEBUG stops being known at build time and the debug branch is live again.
# we do not understand. Both are hard failures; neither is ever treated as # Neither means we are reading output we do not understand. Both are hard
# absence of a problem. # failures; neither is ever treated as absence of a problem.
read_marker() { read_marker() {
_file="$1" _file="$1"
_on=no _on=no
@@ -87,14 +52,9 @@ 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 DEBUG was not resolved at fail "$_file carries both debug markers, so the build-time DEBUG value
build time: the ternary in src/shared/constants.js survived into the was never resolved and the debug branch is still live. Check that build.js
emitted output. This does not mean the debug branch is live in this still defines __BUILD_DEBUG__."
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
@@ -110,43 +70,13 @@ 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 file may carry one, which catches a manifest that has gone stale # emitted bundle 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() {
_find_status=0 _listing="$(find dist -type f -name '*.js' | sort)"
_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 is_listed "$_file"; then if grep -q -x -F "$_file" "$MANIFEST"; then
continue continue
fi fi
if has_marker "$MARKER_ON" "$_file" || if has_marker "$MARKER_ON" "$_file" ||
@@ -183,18 +113,12 @@ 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,11 +84,8 @@ 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,85 +113,6 @@ 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();
@@ -224,11 +145,53 @@ 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 txs = mergeTransactions( const txsByHash = new Map();
(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;
@@ -302,8 +265,4 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud }; return { transactions: filtered, newFraudContracts: newFraud };
} }
module.exports = { module.exports = { fetchRecentTransactions, filterTransactions };
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};

View File

@@ -1,104 +0,0 @@
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,7 +36,6 @@ 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");
@@ -686,339 +685,6 @@ 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
@@ -1220,12 +886,13 @@ 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"]);
}); });
// Regression guard for the duplicate-row bug: for a plain ERC-20 // Documents current behaviour: for a plain ERC-20 transfer the method is
// transfer the method is "transfer", so parseTx does not mark the entry // "transfer", so parseTx does not mark the entry as a contract call in
// as a contract call in the display sense. The native side of that // the display sense and the merge loop does not consolidate the token
// transaction moved no ETH and is represented by the token row, so it // transfer into it. The result is two entries for one transaction: a
// must not survive the merge as a second, zero-value row. // zero-value native row and the real token row. The zero-value row also
test("a plain ERC-20 transfer produces exactly one entry", async () => { // escapes dust filtering because isContractCall is true.
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(
[ [
@@ -1258,15 +925,14 @@ describe("fetchRecentTransactions merge and dedup", () => {
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(2);
expect(txs[0].symbol).toBe("USDC"); expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(txs[0].exactValue).toBe("1.0"); const nativeRow = txs.find((t) => t.symbol === "ETH");
expect(txs[0].direction).toBe("sent"); expect(nativeRow.exactValue).toBe("0.0");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT); expect(nativeRow.isContractCall).toBe(true);
// The surviving row is the token row, and the filters keep it. // And the zero-value row is not removed by the dust filter.
const kept = filterTransactions(txs, filters()).transactions; const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1); expect(kept).toHaveLength(2);
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 () => {