Compare commits
5 Commits
0a5779a120
...
8f908ce5b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f908ce5b2 | |||
| 86cdea5e4e | |||
| f271bcd7b4 | |||
| 93e3f6e4e2 | |||
| 9b957ffd69 |
@@ -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
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -11,7 +11,7 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@yarn install
|
@yarn install --frozen-lockfile
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -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
23
TODO.md
@@ -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)).
|
||||||
|
|||||||
17
build.js
17
build.js
@@ -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" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 .",
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
108
tests/manifest.test.js
Normal 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
104
tests/state.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
52
tests/vaultBackend.test.js
Normal file
52
tests/vaultBackend.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user