Compare commits
6 Commits
585664f9d2
...
b532115234
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b532115234 | ||
| f455b0ae7f | |||
| 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
|
||||
.DS_Store
|
||||
dist
|
||||
|
||||
2
Makefile
2
Makefile
@@ -11,7 +11,7 @@ setup:
|
||||
@script/setup
|
||||
|
||||
install:
|
||||
@yarn install
|
||||
@yarn install --frozen-lockfile
|
||||
|
||||
test:
|
||||
@script/test
|
||||
|
||||
27
README.md
27
README.md
@@ -31,10 +31,13 @@ list exists to detect symbol spoofing attacks and improve UX.
|
||||
```bash
|
||||
git clone https://git.eeqj.de/sneak/autistmask.git
|
||||
cd autistmask
|
||||
make install
|
||||
make setup
|
||||
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:
|
||||
|
||||
- **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/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
|
||||
|
||||
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
||||
@@ -695,8 +711,8 @@ screen, including ExportPrivKey, falls back to Home.
|
||||
`[x]` delete button, plus a "+ Add wallet" button
|
||||
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
|
||||
plus a "+ Add token" button
|
||||
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
|
||||
selector (System / Light / Dark)
|
||||
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
||||
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
||||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
|
||||
resets the RPC and Blockscout endpoints to that network's defaults
|
||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||
@@ -707,7 +723,6 @@ screen, including ExportPrivKey, falls back to Home.
|
||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||
- "Hide transactions from detected fraud contracts" checkbox
|
||||
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||
- "UTC Timestamps" checkbox
|
||||
- Allowed Sites: list with remove buttons
|
||||
- Denied Sites: list with remove buttons
|
||||
- About: project link, license, author, version, release date, and the
|
||||
@@ -1173,8 +1188,8 @@ Currently supported:
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] Tests for mnemonic generation and address derivation
|
||||
- [ ] Tests for xpub derivation and child address generation
|
||||
- [x] Tests for mnemonic generation and address derivation
|
||||
- [x] Tests for xpub derivation and child address generation
|
||||
- [ ] Test on Firefox (Manifest V2)
|
||||
|
||||
### Scam List
|
||||
|
||||
24
TODO.md
24
TODO.md
@@ -44,6 +44,22 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well
|
||||
into Display, next to the theme selector
|
||||
([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)).
|
||||
- 2026-08-11: 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: Known-answer test coverage for the crypto core — BIP-39/BIP-32
|
||||
derivation in `wallet.js` and the Argon2id vault in `vault.js`
|
||||
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
|
||||
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
|
||||
attribution, token-display rule, navigation model
|
||||
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
|
||||
@@ -53,10 +69,18 @@ undefined identifiers, which is how
|
||||
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
||||
all five network destinations documented, password/Settings/Add Wallet
|
||||
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
|
||||
remaining wallets, the selection only moves when it was deleted, and the
|
||||
active-address change is broadcast to connected sites
|
||||
([#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
|
||||
on `next`, with Status and Next Step refreshed
|
||||
([#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
|
||||
// the authoritative answer to "is constants.js in this bundle" — unlike
|
||||
// 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) {
|
||||
return Object.entries(metafile.outputs)
|
||||
.filter(([outFile, info]) => {
|
||||
@@ -115,8 +121,17 @@ async function build() {
|
||||
// build that never gets around to writing one cannot be verified against
|
||||
// a stale list.
|
||||
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(
|
||||
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
|
||||
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
|
||||
@@ -359,16 +359,16 @@ Click the gear icon on the home screen to access settings:
|
||||
- **Wallets**: Your wallets, and "+ Add wallet".
|
||||
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
|
||||
token".
|
||||
- **Display**: Toggle whether tracked tokens with zero balance are shown, and
|
||||
choose the theme (System, Light, or Dark).
|
||||
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
|
||||
timestamps to UTC, and choose the theme (System, Light, or Dark).
|
||||
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
|
||||
resets the RPC and Blockscout endpoints to that network's defaults.
|
||||
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
|
||||
You can use your own node for maximum privacy.
|
||||
- **Blockscout API**: Change the Blockscout instance used for token balances and
|
||||
transaction history. You can use a self-hosted instance.
|
||||
- **Token Spam Protection**: Toggle individual scam filters, set the dust
|
||||
transaction threshold, and switch timestamps to UTC.
|
||||
- **Token Spam Protection**: Toggle individual scam filters and set the dust
|
||||
transaction threshold.
|
||||
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
|
||||
- **About**: License, author, version, release date, and a link to the commit
|
||||
this build came from.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --forceExit",
|
||||
"test:verbose": "jest --forceExit --verbose",
|
||||
"build": "node build.js",
|
||||
"lint": "prettier --check .",
|
||||
"fmt": "prettier --write .",
|
||||
|
||||
@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
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 "$@"
|
||||
|
||||
@@ -34,16 +34,51 @@ fail() {
|
||||
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() {
|
||||
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
|
||||
# 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:
|
||||
# DEBUG stops being known at build time and the debug branch is live again.
|
||||
# Neither means we are reading output we do not understand. Both are hard
|
||||
# failures; neither is ever treated as absence of a problem.
|
||||
# DEBUG stops being known at build time. Neither means we are reading output
|
||||
# we do not understand. Both are hard failures; neither is ever treated as
|
||||
# absence of a problem.
|
||||
read_marker() {
|
||||
_file="$1"
|
||||
_on=no
|
||||
@@ -52,9 +87,14 @@ read_marker() {
|
||||
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
|
||||
|
||||
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
|
||||
fail "$_file carries both debug markers, so the build-time DEBUG value
|
||||
was never resolved and the debug branch is still live. Check that build.js
|
||||
still defines __BUILD_DEBUG__."
|
||||
fail "$_file carries both debug markers, so DEBUG was not resolved at
|
||||
build time: the ternary in src/shared/constants.js survived into the
|
||||
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
|
||||
if [ "$_on" = no ] && [ "$_off" = no ]; then
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# 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() {
|
||||
_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
|
||||
[ -n "$_file" ] || continue
|
||||
if grep -q -x -F "$_file" "$MANIFEST"; then
|
||||
if is_listed "$_file"; then
|
||||
continue
|
||||
fi
|
||||
if has_marker "$MARKER_ON" "$_file" ||
|
||||
@@ -113,12 +183,18 @@ main() {
|
||||
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
|
||||
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
|
||||
while read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
[ -f "$file" ] ||
|
||||
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"
|
||||
[ "$MARKER" = "$expected" ] ||
|
||||
fail "$file is $MARKER but this build expects $expected."
|
||||
|
||||
@@ -869,6 +869,12 @@
|
||||
/>
|
||||
Show tracked tokens with zero balance
|
||||
</label>
|
||||
<label
|
||||
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
|
||||
>
|
||||
<input type="checkbox" id="settings-utc-timestamps" />
|
||||
UTC Timestamps
|
||||
</label>
|
||||
<div class="text-xs flex items-center gap-1">
|
||||
<label for="settings-theme">Theme:</label>
|
||||
<select
|
||||
@@ -979,12 +985,6 @@
|
||||
/>
|
||||
<span class="text-xs text-muted">gwei</span>
|
||||
</div>
|
||||
<label
|
||||
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
|
||||
>
|
||||
<input type="checkbox" id="settings-utc-timestamps" />
|
||||
UTC Timestamps
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
|
||||
@@ -84,8 +84,11 @@ async function loadState() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
if (result.autistmask) {
|
||||
const saved = result.autistmask;
|
||||
state.hasWallet = saved.hasWallet;
|
||||
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.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||
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) {
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = address.toLowerCase();
|
||||
@@ -145,53 +224,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
const txJson = txResp.ok ? await txResp.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);
|
||||
log.debugf("fetchRecentTransactions done, count:", result.length);
|
||||
return result;
|
||||
@@ -265,4 +302,8 @@ function filterTransactions(txs, filters = {}) {
|
||||
return { transactions: filtered, newFraudContracts: newFraud };
|
||||
}
|
||||
|
||||
module.exports = { fetchRecentTransactions, filterTransactions };
|
||||
module.exports = {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
mergeTransactions,
|
||||
};
|
||||
|
||||
111
tests/settingsUtcTimestamps.test.js
Normal file
111
tests/settingsUtcTimestamps.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// Tests for the UTC Timestamps setting.
|
||||
//
|
||||
// The checkbox was moved out of the Token Spam Protection well and into the
|
||||
// Display well next to the theme selector. It is wired by id through the $()
|
||||
// helper, so the move cannot break the handler — but nothing in the suite said
|
||||
// so. These tests pin both halves down: the markup lives in Display and
|
||||
// nowhere else, and the value still round-trips through storage.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const POPUP_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The body of one `<div class="bg-well ...">` well, selected by its heading.
|
||||
function wellWithHeading(html, heading) {
|
||||
const headingIndex = html.indexOf(
|
||||
'<h3 class="font-bold mb-1">' + heading + "</h3>",
|
||||
);
|
||||
expect(headingIndex).toBeGreaterThan(-1);
|
||||
const start = html.lastIndexOf('<div class="bg-well', headingIndex);
|
||||
const end = html.indexOf('<div class="bg-well', headingIndex);
|
||||
return html.slice(start, end === -1 ? html.length : end);
|
||||
}
|
||||
|
||||
describe("the UTC Timestamps checkbox placement", () => {
|
||||
test("the checkbox appears exactly once in the popup markup", () => {
|
||||
const matches = POPUP_HTML.match(/id="settings-utc-timestamps"/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("it renders in the Display well, alongside the theme selector", () => {
|
||||
const display = wellWithHeading(POPUP_HTML, "Display");
|
||||
|
||||
expect(display).toContain('id="settings-utc-timestamps"');
|
||||
expect(display).toContain('id="settings-theme"');
|
||||
});
|
||||
|
||||
test("it does not render in the Token Spam Protection well", () => {
|
||||
const spam = wellWithHeading(POPUP_HTML, "Token Spam Protection");
|
||||
|
||||
expect(spam).not.toContain('id="settings-utc-timestamps"');
|
||||
// The filters that do belong there are untouched.
|
||||
expect(spam).toContain('id="settings-hide-low-holders"');
|
||||
expect(spam).toContain('id="settings-hide-fraud-contracts"');
|
||||
expect(spam).toContain('id="settings-hide-dust"');
|
||||
expect(spam).toContain('id="settings-dust-threshold"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the UTC Timestamps setting round-trips through storage", () => {
|
||||
let store;
|
||||
|
||||
function loadStateModule() {
|
||||
store = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: async (key) =>
|
||||
key in store ? { [key]: store[key] } : {},
|
||||
set: async (obj) => Object.assign(store, obj),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.resetModules();
|
||||
return require("../src/shared/state");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
test("defaults to off with nothing persisted", async () => {
|
||||
const { state, loadState } = loadStateModule();
|
||||
|
||||
await loadState();
|
||||
|
||||
expect(state.utcTimestamps).toBe(false);
|
||||
});
|
||||
|
||||
test("an enabled checkbox is persisted and read back", async () => {
|
||||
const first = loadStateModule();
|
||||
|
||||
// What the change handler in views/settings.js does.
|
||||
first.state.utcTimestamps = true;
|
||||
await first.saveState();
|
||||
expect(store.autistmask.utcTimestamps).toBe(true);
|
||||
|
||||
// A fresh popup load sees it.
|
||||
jest.resetModules();
|
||||
const second = require("../src/shared/state");
|
||||
expect(second.state.utcTimestamps).toBe(false);
|
||||
await second.loadState();
|
||||
expect(second.state.utcTimestamps).toBe(true);
|
||||
});
|
||||
|
||||
test("turning it back off is persisted too", async () => {
|
||||
const { state, saveState, loadState } = loadStateModule();
|
||||
|
||||
state.utcTimestamps = true;
|
||||
await saveState();
|
||||
state.utcTimestamps = false;
|
||||
await saveState();
|
||||
|
||||
state.utcTimestamps = true;
|
||||
await loadState();
|
||||
expect(state.utcTimestamps).toBe(false);
|
||||
});
|
||||
});
|
||||
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 {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
mergeTransactions,
|
||||
} = require("../src/shared/transactions");
|
||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||
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
|
||||
// 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"]);
|
||||
});
|
||||
|
||||
// Documents current behaviour: for a plain ERC-20 transfer the method is
|
||||
// "transfer", so parseTx does not mark the entry as a contract call in
|
||||
// the display sense and the merge loop does not consolidate the token
|
||||
// transfer into it. The result is two entries for one transaction: a
|
||||
// zero-value native row and the real token row. The zero-value row also
|
||||
// escapes dust filtering because isContractCall is true.
|
||||
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
|
||||
// Regression guard for the duplicate-row bug: for a plain ERC-20
|
||||
// transfer the method is "transfer", so parseTx does not mark the entry
|
||||
// as a contract call in the display sense. The native side of that
|
||||
// transaction moved no ETH and is represented by the token row, so it
|
||||
// must not survive the merge as a second, zero-value row.
|
||||
test("a plain ERC-20 transfer produces exactly one entry", async () => {
|
||||
const hash = "0x" + "5".repeat(64);
|
||||
respondWith(
|
||||
[
|
||||
@@ -925,14 +1258,15 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(txs).toHaveLength(2);
|
||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||
const nativeRow = txs.find((t) => t.symbol === "ETH");
|
||||
expect(nativeRow.exactValue).toBe("0.0");
|
||||
expect(nativeRow.isContractCall).toBe(true);
|
||||
// And the zero-value row is not removed by the dust filter.
|
||||
expect(txs).toHaveLength(1);
|
||||
expect(txs[0].symbol).toBe("USDC");
|
||||
expect(txs[0].exactValue).toBe("1.0");
|
||||
expect(txs[0].direction).toBe("sent");
|
||||
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
|
||||
// The surviving row is the token row, and the filters keep it.
|
||||
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 () => {
|
||||
|
||||
346
tests/vault.test.js
Normal file
346
tests/vault.test.js
Normal file
@@ -0,0 +1,346 @@
|
||||
// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption
|
||||
// that protects recovery phrases and private keys at rest.
|
||||
//
|
||||
// The properties that matter here are the ones whose failure is silent. A
|
||||
// vault that decrypts under the wrong password, that hands back plaintext from
|
||||
// a ciphertext an attacker edited, that reuses a nonce, or that leaves the
|
||||
// recovery phrase readable somewhere in the stored blob all look exactly like
|
||||
// a working vault from the UI. So each test below asserts a negative: the
|
||||
// thing that must not happen.
|
||||
//
|
||||
// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production
|
||||
// interactive parameters, which the module hardcodes. The parameters are not
|
||||
// weakened or overridden anywhere in this file — they are pinned by the "key
|
||||
// derivation cost" tests, since they are the vault's only defence against an
|
||||
// offline attack on a stolen blob. The suite is kept inside script/test's
|
||||
// 30-second budget by sharing one encrypted fixture across the tamper cases
|
||||
// instead of re-encrypting per test.
|
||||
|
||||
const sodium = require("libsodium-wrappers-sumo");
|
||||
const {
|
||||
encryptWithPassword,
|
||||
decryptWithPassword,
|
||||
} = require("../src/shared/vault");
|
||||
|
||||
// A publicly known development phrase. Never fund it.
|
||||
const SECRET = "test test test test test test test test test test test junk";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
const WRONG_PASSWORD = "correct horse battery stapl";
|
||||
|
||||
const SALT_BYTES = 16;
|
||||
const NONCE_BYTES = 24;
|
||||
const POLY1305_TAG_BYTES = 16;
|
||||
|
||||
const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
||||
|
||||
function b64decode(s) {
|
||||
return sodium.from_base64(s);
|
||||
}
|
||||
|
||||
// A shallow copy with one field replaced, so the shared fixture is never
|
||||
// mutated by a tamper test.
|
||||
function withField(blob, field, value) {
|
||||
return { ...blob, [field]: value };
|
||||
}
|
||||
|
||||
// Flip the low bit of one byte of a base64-encoded field.
|
||||
function flipByte(b64, index) {
|
||||
const bytes = b64decode(b64);
|
||||
bytes[index] ^= 0x01;
|
||||
return sodium.to_base64(bytes);
|
||||
}
|
||||
|
||||
let vault;
|
||||
|
||||
beforeAll(async () => {
|
||||
await sodium.ready;
|
||||
vault = await encryptWithPassword(SECRET, PASSWORD);
|
||||
});
|
||||
|
||||
describe("stored blob shape", () => {
|
||||
test("is exactly the documented { salt, nonce, ciphertext }", () => {
|
||||
expect(Object.keys(vault).sort()).toEqual([
|
||||
"ciphertext",
|
||||
"nonce",
|
||||
"salt",
|
||||
]);
|
||||
});
|
||||
|
||||
test("every field is a base64 string", () => {
|
||||
for (const field of ["salt", "nonce", "ciphertext"]) {
|
||||
expect(typeof vault[field]).toBe("string");
|
||||
expect(vault[field]).toMatch(BASE64);
|
||||
}
|
||||
});
|
||||
|
||||
test("salt and nonce are full length", () => {
|
||||
expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES);
|
||||
expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES);
|
||||
});
|
||||
|
||||
test("ciphertext carries a Poly1305 authentication tag", () => {
|
||||
expect(b64decode(vault.ciphertext)).toHaveLength(
|
||||
SECRET.length + POLY1305_TAG_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
test("the blob survives JSON storage unchanged", async () => {
|
||||
const stored = JSON.parse(JSON.stringify(vault));
|
||||
|
||||
await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe(
|
||||
SECRET,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no plaintext leakage", () => {
|
||||
test("the secret does not appear in the serialized vault", () => {
|
||||
const serialized = JSON.stringify(vault);
|
||||
|
||||
expect(serialized).not.toContain(SECRET);
|
||||
for (const word of new Set(SECRET.split(" "))) {
|
||||
expect(serialized).not.toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
test("the ciphertext bytes do not contain the secret bytes", () => {
|
||||
const bytes = Buffer.from(b64decode(vault.ciphertext));
|
||||
|
||||
expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false);
|
||||
// Not even the first word, which would betray an unencrypted prefix.
|
||||
expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false);
|
||||
});
|
||||
|
||||
test("the password does not appear in the serialized vault", () => {
|
||||
expect(JSON.stringify(vault)).not.toContain(PASSWORD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round trip", () => {
|
||||
test("decrypts back to the original secret", async () => {
|
||||
await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe(
|
||||
SECRET,
|
||||
);
|
||||
});
|
||||
|
||||
test("survives a non-ASCII plaintext byte for byte", async () => {
|
||||
const unicode = "recovery phrase é中文\u{1f600}";
|
||||
|
||||
const blob = await encryptWithPassword(unicode, PASSWORD);
|
||||
|
||||
await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe(
|
||||
unicode,
|
||||
);
|
||||
});
|
||||
|
||||
test("an empty password still round-trips and is not a bypass", async () => {
|
||||
const blob = await encryptWithPassword(SECRET, "");
|
||||
|
||||
await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET);
|
||||
// An empty password must not act as a skeleton key on other vaults,
|
||||
// nor may a real password open an empty-password vault.
|
||||
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
|
||||
await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fresh salt and nonce", () => {
|
||||
test("two encryptions of the same plaintext differ in all three fields", async () => {
|
||||
const second = await encryptWithPassword(SECRET, PASSWORD);
|
||||
|
||||
expect(second.salt).not.toBe(vault.salt);
|
||||
expect(second.nonce).not.toBe(vault.nonce);
|
||||
expect(second.ciphertext).not.toBe(vault.ciphertext);
|
||||
await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe(
|
||||
SECRET,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key derivation cost", () => {
|
||||
// Argon2id's opslimit and memlimit are the whole of the vault's resistance
|
||||
// to an offline attack on a stolen blob, and lowering them breaks nothing
|
||||
// any other test here can see — the suite merely runs faster. So pin them
|
||||
// directly, both to libsodium's INTERACTIVE constants and to the absolute
|
||||
// values those constants must keep meaning.
|
||||
const INTERACTIVE_OPSLIMIT = 2;
|
||||
const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024;
|
||||
|
||||
test("the interactive constants still mean 2 passes over 64 MiB", () => {
|
||||
expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe(
|
||||
INTERACTIVE_OPSLIMIT,
|
||||
);
|
||||
expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe(
|
||||
INTERACTIVE_MEMLIMIT,
|
||||
);
|
||||
// The floor these must never quietly be swapped for: _MIN is one pass
|
||||
// over 8 KiB, an 8192x reduction in memory cost.
|
||||
expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan(
|
||||
INTERACTIVE_OPSLIMIT,
|
||||
);
|
||||
expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan(
|
||||
INTERACTIVE_MEMLIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
test("a key derived at the interactive parameters opens the vault", () => {
|
||||
// Independent of any spy, and of the module's own code path: derive
|
||||
// the key here from the vault's published salt at the interactive cost
|
||||
// and open its ciphertext directly. A vault whose key came from any
|
||||
// other opslimit, memlimit or Argon2id variant yields a different key
|
||||
// and cannot be opened this way.
|
||||
const key = sodium.crypto_pwhash(
|
||||
sodium.crypto_secretbox_KEYBYTES,
|
||||
PASSWORD,
|
||||
b64decode(vault.salt),
|
||||
INTERACTIVE_OPSLIMIT,
|
||||
INTERACTIVE_MEMLIMIT,
|
||||
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
const opened = sodium.crypto_secretbox_open_easy(
|
||||
b64decode(vault.ciphertext),
|
||||
b64decode(vault.nonce),
|
||||
key,
|
||||
);
|
||||
|
||||
expect(sodium.to_string(opened)).toBe(SECRET);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"encrypt",
|
||||
async () => {
|
||||
await encryptWithPassword(SECRET, PASSWORD);
|
||||
},
|
||||
],
|
||||
[
|
||||
"decrypt",
|
||||
async () => {
|
||||
await decryptWithPassword(vault, PASSWORD);
|
||||
},
|
||||
],
|
||||
])("%s derives exactly one key at the interactive cost", async (_, run) => {
|
||||
const spy = jest.spyOn(sodium, "crypto_pwhash");
|
||||
try {
|
||||
await run();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [keyBytes, , salt, opslimit, memlimit, alg] =
|
||||
spy.mock.calls[0];
|
||||
expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES);
|
||||
expect(salt).toHaveLength(SALT_BYTES);
|
||||
expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE);
|
||||
expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE);
|
||||
expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrong password", () => {
|
||||
test("is rejected, and rejects cleanly", async () => {
|
||||
// rejects.toThrow asserts a rejected promise, not a synchronous throw
|
||||
// and not an unhandled rejection: the caller can catch this.
|
||||
await expect(
|
||||
decryptWithPassword(vault, WRONG_PASSWORD),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("returns no plaintext, not even partially", async () => {
|
||||
const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch(
|
||||
(err) => err,
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(String(result)).not.toContain("test");
|
||||
});
|
||||
|
||||
test("the empty password is rejected on a password-protected vault", async () => {
|
||||
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tampering", () => {
|
||||
test("a flipped ciphertext bit is rejected by the auth tag", async () => {
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
flipByte(vault.ciphertext, 0),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a flipped bit in the authentication tag itself is rejected", async () => {
|
||||
const tagStart = b64decode(vault.ciphertext).length - 1;
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
flipByte(vault.ciphertext, tagStart),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a flipped nonce bit is rejected", async () => {
|
||||
const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0));
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a flipped salt bit is rejected", async () => {
|
||||
const tampered = withField(vault, "salt", flipByte(vault.salt, 0));
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a truncated ciphertext is rejected", async () => {
|
||||
const bytes = b64decode(vault.ciphertext);
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
sodium.to_base64(bytes.slice(0, bytes.length - 4)),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a ciphertext shorter than the auth tag is rejected", async () => {
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a truncated nonce is rejected", async () => {
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"nonce",
|
||||
sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a ciphertext from another vault is rejected", async () => {
|
||||
const other = await encryptWithPassword("a different secret", PASSWORD);
|
||||
const spliced = withField(vault, "ciphertext", other.ciphertext);
|
||||
|
||||
await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a missing field is rejected rather than decrypted", async () => {
|
||||
for (const field of ["salt", "nonce", "ciphertext"]) {
|
||||
const broken = { ...vault };
|
||||
delete broken[field];
|
||||
|
||||
await expect(
|
||||
decryptWithPassword(broken, PASSWORD),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
// Tests for the DEBUG build flag as it gates mnemonic generation.
|
||||
// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic
|
||||
// generation (first two describes), and HD key derivation against published
|
||||
// known-answer vectors (rest of the file).
|
||||
//
|
||||
// The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle
|
||||
// time. Under jest the global is absent, which is exactly the release-build
|
||||
@@ -92,3 +94,317 @@ describe("generateMnemonic in a debug build", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key derivation.
|
||||
//
|
||||
// Every address below is a published constant, not something this codebase
|
||||
// produced. Asserting against what the implementation happens to return today
|
||||
// would pass just as happily with the wrong coin type, the wrong path depth or
|
||||
// a non-empty seed passphrase, all of which silently send funds to addresses
|
||||
// no other wallet can recover.
|
||||
//
|
||||
// Vector sources:
|
||||
//
|
||||
// VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard
|
||||
// development recovery phrase and the first three accounts it yields at
|
||||
// m/44'/60'/0'/0/n with an empty seed passphrase, as published in the
|
||||
// Hardhat and Ganache documentation. Publicly known; never fund it.
|
||||
//
|
||||
// ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy
|
||||
// phrase (Trezor's official BIP-39 vector set, first entry) and its
|
||||
// m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second,
|
||||
// independently published phrase so the pin is not one vector deep.
|
||||
//
|
||||
// BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1
|
||||
// (seed 000102030405060708090a0b0c0d0e0f).
|
||||
//
|
||||
// The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the
|
||||
// published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD
|
||||
// path must meet at the same address from two different directions.
|
||||
|
||||
const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers");
|
||||
const wallet = require("../src/shared/wallet");
|
||||
const { BIP44_ETH_PATH } = require("../src/shared/constants");
|
||||
|
||||
const VECTOR_PHRASE =
|
||||
"test test test test test test test test test test test junk";
|
||||
|
||||
const VECTOR_ADDRESSES = [
|
||||
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
|
||||
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
||||
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
|
||||
];
|
||||
|
||||
const VECTOR_PRIVATE_KEYS = [
|
||||
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
|
||||
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
|
||||
];
|
||||
|
||||
const ZERO_ENTROPY_PHRASE =
|
||||
"abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about";
|
||||
const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94";
|
||||
|
||||
const BIP32_VECTOR_1_XPRV =
|
||||
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" +
|
||||
"ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi";
|
||||
|
||||
// The master (depth-0) extended private key for a phrase, which is what the
|
||||
// import-an-xprv flow is handed. Built with ethers rather than with the module
|
||||
// under test, so hdWalletFromXprv is not being checked against itself.
|
||||
function masterXprv(phrase, passphrase = "") {
|
||||
return HDNodeWallet.fromSeed(
|
||||
Mnemonic.fromPhrase(phrase, passphrase).computeSeed(),
|
||||
).extendedKey;
|
||||
}
|
||||
|
||||
describe("hdWalletFromMnemonic", () => {
|
||||
test("first address matches the published vector for m/44'/60'/0'/0/0", () => {
|
||||
expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe(
|
||||
VECTOR_ADDRESSES[0],
|
||||
);
|
||||
});
|
||||
|
||||
test("second published phrase derives its published address", () => {
|
||||
expect(
|
||||
wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress,
|
||||
).toBe(ZERO_ENTROPY_ADDRESS);
|
||||
});
|
||||
|
||||
test("returns the account-level xpub, which is watch-only", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
expect(xpub.startsWith("xpub")).toBe(true);
|
||||
// A neutered ethers node exposes no private key at all, so accept
|
||||
// either absent or null rather than pinning which.
|
||||
expect(
|
||||
HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null,
|
||||
).toBeNull();
|
||||
expect(wallet.isValidXprv(xpub)).toBe(false);
|
||||
});
|
||||
|
||||
test("the account path is the documented BIP-44 Ethereum path", () => {
|
||||
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
|
||||
});
|
||||
|
||||
test("rejects an invalid recovery phrase rather than deriving from it", () => {
|
||||
expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAddressFromXpub", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
test.each([0, 1, 2])(
|
||||
"child %i matches the published vector address",
|
||||
(index) => {
|
||||
expect(wallet.deriveAddressFromXpub(xpub, index)).toBe(
|
||||
VECTOR_ADDRESSES[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("agrees with hdWalletFromMnemonic at index 0", () => {
|
||||
expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(
|
||||
wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects garbage instead of returning an address", () => {
|
||||
expect(() =>
|
||||
wallet.deriveAddressFromXpub("xpub-nonsense", 0),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hdWalletFromMnemonic seed passphrase handling", () => {
|
||||
// The vectors above are only reproducible with an empty BIP-39 seed
|
||||
// passphrase. This pins that the empty string reaching
|
||||
// HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the
|
||||
// published address is unreachable, and a wallet derived that way could
|
||||
// not be restored anywhere else from the phrase alone.
|
||||
test("a non-empty seed passphrase would yield a different address", () => {
|
||||
const withPassphrase = HDNodeWallet.fromPhrase(
|
||||
VECTOR_PHRASE,
|
||||
"TREZOR",
|
||||
BIP44_ETH_PATH,
|
||||
).deriveChild(0).address;
|
||||
|
||||
expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hdWalletFromXprv", () => {
|
||||
// hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while
|
||||
// hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a
|
||||
// depth-0 master key the two are the same derivation; these tests pin that
|
||||
// equivalence to a published address rather than assuming it.
|
||||
test("master xprv for the vector phrase yields the vector address", () => {
|
||||
expect(
|
||||
wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress,
|
||||
).toBe(VECTOR_ADDRESSES[0]);
|
||||
});
|
||||
|
||||
test("agrees with hdWalletFromMnemonic on xpub and address", () => {
|
||||
const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
|
||||
|
||||
expect(fromXprv).toEqual(fromPhrase);
|
||||
});
|
||||
|
||||
test("derived xpub generates the same child addresses", () => {
|
||||
const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
|
||||
|
||||
expect(
|
||||
[0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
|
||||
).toEqual(VECTOR_ADDRESSES);
|
||||
});
|
||||
|
||||
test("accepts the BIP-32 test vector 1 master key", () => {
|
||||
const { xpub, firstAddress } =
|
||||
wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
|
||||
|
||||
expect(xpub.startsWith("xpub")).toBe(true);
|
||||
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
||||
});
|
||||
|
||||
test("rejects a watch-only xpub", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
expect(() => wallet.hdWalletFromXprv(xpub)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects garbage", () => {
|
||||
expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidXprv", () => {
|
||||
test.each([
|
||||
["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true],
|
||||
["the empty string", "", false],
|
||||
["garbage", "not-a-key", false],
|
||||
["a bare private key", VECTOR_PRIVATE_KEYS[0], false],
|
||||
["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false],
|
||||
["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false],
|
||||
])("%s -> %s", (_name, key, expected) => {
|
||||
expect(wallet.isValidXprv(key)).toBe(expected);
|
||||
});
|
||||
|
||||
test("a watch-only xpub is not an xprv", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
expect(wallet.isValidXprv(xpub)).toBe(false);
|
||||
});
|
||||
|
||||
// Skipped: this asserts the correct behaviour, which the code does not
|
||||
// currently have. isValidXprv gates the paste-your-extended-private-key
|
||||
// import in src/popup/views/addWallet.js:215, and it accepts a key with a
|
||||
// one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58
|
||||
// checksum verification whenever the decoded payload is the usual 82
|
||||
// bytes, which is the whole point of that checksum. Measured on this
|
||||
// vector: changing any one of the last 14 characters passes validation,
|
||||
// and for 9 of those 14 positions the import silently yields a *different*
|
||||
// wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of
|
||||
// 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown.
|
||||
// Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; out of scope
|
||||
// here, which is tests only. Unskip when it is fixed.
|
||||
test.skip("rejects an extended key with a one-character typo", () => {
|
||||
const index = BIP32_VECTOR_1_XPRV.length - 8;
|
||||
const typo =
|
||||
BIP32_VECTOR_1_XPRV.slice(0, index) +
|
||||
(BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") +
|
||||
BIP32_VECTOR_1_XPRV.slice(index + 1);
|
||||
|
||||
expect(wallet.isValidXprv(typo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidMnemonic", () => {
|
||||
test.each([
|
||||
["the vector phrase", VECTOR_PHRASE, true],
|
||||
["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true],
|
||||
[
|
||||
"a 12-word phrase with a bad checksum",
|
||||
"abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon abandon",
|
||||
false,
|
||||
],
|
||||
["an 11-word phrase", "abandon ".repeat(10) + "about", false],
|
||||
["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false],
|
||||
["the empty string", "", false],
|
||||
["garbage", "correct horse battery staple", false],
|
||||
])("%s -> %s", (_name, phrase, expected) => {
|
||||
expect(wallet.isValidMnemonic(phrase)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addressFromPrivateKey", () => {
|
||||
test.each([0, 1, 2])(
|
||||
"published key %i yields its published address",
|
||||
(index) => {
|
||||
expect(
|
||||
wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]),
|
||||
).toBe(VECTOR_ADDRESSES[index]);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a key of the wrong length", () => {
|
||||
expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects the empty string", () => {
|
||||
expect(() => wallet.addressFromPrivateKey("")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSignerForAddress", () => {
|
||||
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "hd" },
|
||||
index,
|
||||
VECTOR_PHRASE,
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
|
||||
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
|
||||
});
|
||||
|
||||
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
index,
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
|
||||
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
|
||||
});
|
||||
|
||||
test("single private key ignores the address index", () => {
|
||||
for (const index of [0, 1, 2]) {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "privkey" },
|
||||
index,
|
||||
VECTOR_PRIVATE_KEYS[1],
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(VECTOR_ADDRESSES[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test("the returned signer signs recoverably as the expected address", async () => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "hd" },
|
||||
1,
|
||||
VECTOR_PHRASE,
|
||||
);
|
||||
const message = "AutistMask derivation test";
|
||||
|
||||
const signature = await signer.signMessage(message);
|
||||
|
||||
expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user