Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
ce81596100 fix: count the network fee in the confirm-screen balance check (closes #154)
All checks were successful
check / check (push) Successful in 35s
The Send button was enabled whenever the amount alone fit the balance, so
a max-value ETH send passed the confirmation screen and failed at
broadcast, after the user had committed to it.

The arithmetic moves into src/shared/txValidation.js as a pure function
over 18-decimal fixed point: native ETH now requires amount + fee <=
balance, and an ERC-20 transfer requires the ETH balance to cover the fee
on top of the token check, reported as its own error. Validation re-runs
when the async estimate resolves; Send stays disabled while the estimate
is pending and when it fails, so an unknown fee is never treated as zero.
The fee messages are static elements that already reserve their space, so
nothing moves when the estimate lands.

The fee reserved is the one the node will actually require. The send pins
no fee fields, so ethers broadcasts a type-2 transaction and the node
validates it against value + gasLimit * maxFeePerGas; gating on gasPrice
would under-reserve by roughly gasLimit * baseFee and let through exactly
the broadcast failure this change exists to prevent. feeReserveWei()
derives that reserve, falling back to gasPrice only where no type-2
pricing exists, and the fee shown on screen is the same figure so the
displayed number and the gate can never contradict each other.

validateTransfer() fails closed: a feeWei that is not a non-negative
bigint under FEE_KNOWN, and any unrecognised feeStatus, block exactly as
FEE_UNAVAILABLE does rather than counting as a fee of zero.
2026-08-11 12:42:10 +00:00
20 changed files with 147 additions and 1650 deletions

View File

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

View File

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

View File

@@ -31,13 +31,10 @@ list exists to detect symbol spoofing attacks and improve UX.
```bash
git clone https://git.eeqj.de/sneak/autistmask.git
cd autistmask
make setup
make install
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
@@ -100,19 +97,6 @@ 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
@@ -575,26 +559,19 @@ screen, including ExportPrivKey, falls back to Home.
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses)
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the
`gasLimit * maxFeePerGas` reserve the node requires, which is what the
balance check gates on. The second line is omitted on a network with no
type-2 pricing, where the two are the same number, but its space is
reserved either way
- Maximum network fee: "Estimating..." then the ETH amount (USD in
parentheses) or "Unable to estimate", fetched async. This is the
`gasLimit * maxFeePerGas` reserve the node requires, which is also what
the balance check gates on; the transaction typically costs less once the
base fee is settled
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
- Errors (insufficient balance), plus three reserved error boxes — the
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
estimated. The first two are mutually exclusive per transfer type, so only
the applicable one holds space
- Errors (insufficient balance)
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, and while the network fee
estimate is pending or unavailable)
- "Sign & Send" button (disabled if errors)
- **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
@@ -1118,8 +1095,7 @@ indexes it as a real token transfer.
it. AutistMask hides transactions below a configurable dust threshold
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
dust while low enough to preserve any transfer a user would plausibly care
about. The threshold is user-configurable in Settings; a threshold of `0`
hides nothing, exactly as clearing the checkbox does.
about. The threshold is user-configurable in Settings.
- **User-configurable**: All of the above filters (known symbol verification,
low-holder threshold, fraud contract blocklist, dust threshold) are settings
@@ -1200,8 +1176,8 @@ Currently supported:
### Testing
- [x] Tests for mnemonic generation and address derivation
- [x] Tests for xpub derivation and child address generation
- [ ] Tests for mnemonic generation and address derivation
- [ ] Tests for xpub derivation and child address generation
- [ ] Test on Firefox (Manifest V2)
### Scam List

26
TODO.md
View File

@@ -49,24 +49,6 @@ undefined identifiers, which is how
transaction, with the arithmetic in a pure, unit-tested
`src/shared/txValidation.js`
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
falling back to the 100,000 gwei default, and every address comparison in
`src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 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)).
@@ -76,18 +58,10 @@ 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)).

View File

@@ -29,12 +29,6 @@ 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]) => {
@@ -121,17 +115,8 @@ 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(
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
{ stdio: "inherit" },
);

View File

@@ -265,10 +265,8 @@ The confirmation screen shows:
- **From and To addresses** with identicons and Etherscan links
- **Amount** with USD estimate
- **Your current balance** with USD estimate
- **Network fee** — what the transfer is expected to cost, in ETH with a USD
estimate, and below it the larger amount reserved until it confirms. The
reserve is what the network requires up front and what the balance check gates
on; the refund of the difference is why the two differ
- **Maximum network fee** in ETH with USD estimate — the reserve the network
requires, which the balance check gates on; the transaction usually costs less
- **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan

View File

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

View File

@@ -7,13 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Running tests..."
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
}
timeout 30 yarn run test 2>&1
}
main "$@"

View File

@@ -34,51 +34,16 @@ 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() {
_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
grep -q -F "$1" "$2" 2>/dev/null
}
# 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. 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 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.
read_marker() {
_file="$1"
_on=no
@@ -87,14 +52,9 @@ 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 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__."
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__."
fi
if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be
@@ -110,43 +70,13 @@ read_marker() {
}
# The manifest says which bundles must carry a marker. This says no other
# emitted file may carry one, which catches a manifest that has gone stale
# emitted bundle may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list.
#
# 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() {
_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)"
_listing="$(find dist -type f -name '*.js' | sort)"
while read -r _file; do
[ -n "$_file" ] || continue
if is_listed "$_file"; then
if grep -q -x -F "$_file" "$MANIFEST"; then
continue
fi
if has_marker "$MARKER_ON" "$_file" ||
@@ -183,18 +113,12 @@ 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."

View File

@@ -582,18 +582,10 @@
<div id="confirm-balance" class="text-xs"></div>
</div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div class="text-xs text-muted mb-1">Network fee</div>
<div id="confirm-fee-amount" class="text-xs"></div>
<!-- Holds its one line of space from the first paint, so
the reserve appearing when the estimate lands moves
nothing. The placeholder is never seen. -->
<div
id="confirm-fee-reserve"
class="text-xs text-muted"
style="visibility: hidden"
>
reserve pending
<div class="text-xs text-muted mb-1">
Maximum network fee
</div>
<div id="confirm-fee-amount" class="text-xs"></div>
</div>
<div
id="confirm-warnings"

View File

@@ -38,7 +38,6 @@ const {
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
@@ -184,7 +183,6 @@ function show(txInfo) {
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
$("confirm-fee-amount").textContent = "Estimating...";
setVisible("confirm-fee-reserve", false);
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");
attachCopyHandlers("view-confirm-tx");
@@ -287,14 +285,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// A fee in wei as an ETH string, truncated to 6 decimal places.
function formatFeeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
@@ -316,43 +306,28 @@ async function estimateGas(txInfo) {
});
}
// What the node will require to be reserved, which is what the gate
// must be: the send pins no fee fields, so it is broadcast as a
// type-2 transaction priced at maxFeePerGas.
// What the node will require to be reserved, which is what both the
// gate and the displayed figure must be: the send pins no fee fields,
// so it is broadcast as a type-2 transaction priced at maxFeePerGas.
const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) {
throw new Error("no usable gas price from the provider");
}
// What the transaction is expected to cost, which is a different and
// usually much smaller number. Both are shown: quoting only the
// reserve overstates the typical cost by roughly double on mainnet,
// and quoting only the estimate contradicts the balance check.
const estimateWei = feeEstimateWei(gasLimit, feeData);
// The user may have left this transaction while the estimate was in
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
const gasCostEth = formatEther(gasCostWei);
// Format to 6 significant decimal places
const parts = gasCostEth.split(".");
const dec =
parts.length > 1
? parts[1].slice(0, 6).replace(/0+$/, "") || "0"
: "0";
const feeStr = parts[0] + "." + dec + " ETH";
const ethPrice = getPrice("ETH");
const usd = (wei) =>
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = valueWithUsd(
"~" + formatFeeEth(estimateWei),
usd(estimateWei),
);
$("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: a network with no type-2 pricing charges
// exactly what is reserved.
$("confirm-fee-amount").textContent = valueWithUsd(
formatFeeEth(gasCostWei),
usd(gasCostWei),
);
setVisible("confirm-fee-reserve", false);
}
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
renderValidation(txInfo);
@@ -360,7 +335,6 @@ async function estimateGas(txInfo) {
log.errorf("gas estimation failed:", e.message);
if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false);
feeStatus = FEE_UNAVAILABLE;
feeWei = null;
renderValidation(txInfo);

View File

@@ -304,17 +304,11 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => {
const raw = $("settings-dust-threshold").value.trim();
const val = Number(raw);
// 0 is accepted and means "hide nothing". Empty, negative,
// fractional and non-numeric input is rejected outright rather than
// coerced, and the field is put back to the stored threshold so it
// never shows a value the wallet is not using.
if (raw !== "" && Number.isInteger(val) && val >= 0) {
const val = parseInt($("settings-dust-threshold").value, 10);
if (!isNaN(val) && val >= 0) {
state.dustThresholdGwei = val;
await saveState();
}
$("settings-dust-threshold").value = state.dustThresholdGwei;
});
$("settings-utc-timestamps").checked = state.utcTimestamps;

View File

@@ -84,11 +84,8 @@ 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;

View File

@@ -10,14 +10,6 @@ const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
// this file goes through this helper, so an address arriving in checksummed
// or upper-case form can never be read as a different address.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
@@ -38,10 +30,10 @@ function parseTx(tx, addrLower) {
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
let rawUnit = "wei";
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
if (token) {
symbol = token.symbol;
}
@@ -95,8 +87,7 @@ function parseTokenTransfer(tt, addrLower) {
const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0";
const direction =
normalizeAddress(from) === addrLower ? "sent" : "received";
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
return {
hash: tt.transaction_hash,
@@ -113,95 +104,18 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false,
contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "",
),
contractAddress: (
tt.token?.address_hash ||
tt.token?.address ||
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10),
};
}
// 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 = normalizeAddress(address);
const addrLower = address.toLowerCase();
const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -231,11 +145,53 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
const txsByHash = new Map();
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;
@@ -250,38 +206,34 @@ function isSpoofedSymbol(tx) {
if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
return tx.contractAddress !== legit;
}
// Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) {
const fraudSet = new Set(
(filters.fraudContracts || []).map(normalizeAddress),
(filters.fraudContracts || []).map((a) => a.toLowerCase()),
);
// The dust threshold defaults only when it is unset (nullish): a
// threshold of 0 is a real value meaning "hide nothing", since no
// transaction has a value below 0 gwei. It is therefore equivalent to
// clearing the hide-dust checkbox, and the two controls cannot override
// each other in either direction.
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
const newFraud = [];
const filtered = [];
for (const tx of txs) {
const contract = normalizeAddress(tx.contractAddress);
// Always filter spoofed known symbols and record the fraud contract
if (isSpoofedSymbol(tx)) {
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
fraudSet.add(tx.contractAddress);
newFraud.push(tx.contractAddress);
}
continue;
}
// Filter fraud contracts if setting is on
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
if (
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
continue;
}
@@ -302,7 +254,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions &&
!tx.isContractCall &&
tx.valueGwei !== null &&
tx.valueGwei < dustThresholdGwei
tx.valueGwei < (filters.dustThresholdGwei || 100000)
) {
continue;
}
@@ -313,8 +265,4 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud };
}
module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};
module.exports = { fetchRecentTransactions, filterTransactions };

View File

@@ -20,7 +20,7 @@ const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on.
// The amount is not a number we can do exact arithmetic on.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
@@ -58,21 +58,6 @@ function feeReserveWei(gasLimit, feeData) {
return gasLimit * price;
}
// What the transaction is expected to actually cost, in wei — not what must
// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas
// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that,
// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network
// with no type-2 pricing the two are the same number.
//
// Display only: nothing gates on this. Returns null on the same unusable
// inputs as feeReserveWei().
function feeEstimateWei(gasLimit, feeData) {
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
const price = feeData?.gasPrice ?? feeData?.maxFeePerGas;
if (typeof price !== "bigint" || price < 0n) return null;
return gasLimit * price;
}
// Scale a human decimal string to 18-decimal fixed point. Returns null when
// the value is not a decimal number or carries more precision than the scale
// can hold, which the caller must treat as unusable rather than as zero.
@@ -90,9 +75,7 @@ function toFixedPoint(value) {
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// amount — human decimal string being sent
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
@@ -116,10 +99,7 @@ function validateTransfer({
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
if (amountFp === null) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
@@ -165,7 +145,6 @@ module.exports = {
FEE_UNAVAILABLE,
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
toFixedPoint,
validateTransfer,
};

View File

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

View File

@@ -36,7 +36,6 @@ global.chrome = { storage: { local: {} } };
const {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
} = require("../src/shared/transactions");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { debugFetch } = require("../src/shared/log");
@@ -329,42 +328,18 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([]);
});
// Regression guard (#179): EIP-55 mixed case is a checksum over the
// address, not part of its identity, so the contract comparison must be
// case-insensitive in both directions — a genuine token in any casing is
// genuine, and a spoof cannot escape detection by changing its casing.
test("a genuine contract in all-lowercase form is not a spoof", () => {
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
const tx = tokenTx({
// Documents current behaviour, not desired behaviour: the spoof check
// compares tx.contractAddress against a lowercased known address with
// ===, so a caller passing a checksummed address for a genuine token has
// it treated as a spoof. In the app this cannot happen because
// parseTokenTransfer lowercases, but the exported function is not
// defensive about it the way the blocklist check is.
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
const genuineButChecksummed = tokenTx({
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine contract in all-uppercase form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
const tx = tokenTx({
contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
});
const result = filterTransactions([tx], filters());
const result = filterTransactions([genuineButChecksummed], filters());
expect(result.transactions).toEqual([]);
// The recorded fraud contract is normalised, so the persisted
// blocklist matches later transfers whatever casing they arrive in.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
// Documents current behaviour: README.md:810-814 says all four filters
@@ -436,21 +411,6 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => {
expect(tx.holders).toBeNull();
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
// Regression guard (#179): an unknown holder count on a real token — the
// explorer rate-limited the call, or a self-hosted instance omits the
// field — must not be read as zero holders. Reading it that way hides a
// legitimate transfer from the user's history, the same over-filtering
// harm as the zero-threshold bug. This pins the `tx.holders !== null`
// guard, which no fixture previously reached.
test("a token whose holder count is unknown is not filtered", () => {
const tx = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: null,
});
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
});
describe("fraud contract blocklist", () => {
@@ -614,50 +574,16 @@ describe("dust threshold filtering", () => {
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
// not an absent one. It used to be swallowed by `|| 100000`, so the one
// value a user would pick to see everything was the one that did not
// work.
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
const dust = dustOf(50);
const zero = dustOf(0);
const opts = filters({ dustThresholdGwei: 0 });
expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
});
test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
const tx = nativeDustTransfer();
const thresholdZero = filterTransactions(
[tx],
// Documents current behaviour: the threshold is read as
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
// to 0 (the natural way to ask for no dust filtering while leaving the
// toggle on) silently gets the 100,000 gwei default instead.
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
const result = filterTransactions(
[dustOf(50)],
filters({ dustThresholdGwei: 0 }),
);
const toggleOff = filterTransactions(
[tx],
filters({ hideDustTransactions: false }),
);
expect(thresholdZero.transactions).toEqual([tx]);
expect(toggleOff.transactions).toEqual([tx]);
});
test("0, unset and a set threshold are three distinct behaviours", () => {
const tx = dustOf(50);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
.transactions,
).toEqual([]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
.transactions,
).toEqual([]);
expect(result.transactions).toEqual([]);
});
});
@@ -759,339 +685,6 @@ describe("legitimate transactions are never filtered", () => {
});
});
// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes. One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------
// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "",
exactValue: "",
rawAmount: "",
rawUnit: "",
valueGwei: 0,
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
...overrides,
});
}
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
method: "transfer",
...overrides,
});
}
describe("mergeTransactions: one row per value movement", () => {
const HASH = "0x" + "d".repeat(64);
const OTHER_HASH = "0x" + "e".repeat(64);
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
test("a plain ERC-20 transfer yields one row, the token row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
});
test("an ETH-only transfer keeps its row unchanged", () => {
const merged = mergeTransactions([legitimateEthSend()], []);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual(legitimateEthSend());
});
test("a genuine zero-value native transaction is still displayed", () => {
const zero = nativeTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([zero], []);
expect(merged).toEqual([zero]);
});
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
const zero = erc20CallTx({ hash: HASH });
const unrelated = tokenTx({ hash: OTHER_HASH });
const merged = mergeTransactions([zero], [unrelated]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.hash).sort()).toEqual(
[HASH, OTHER_HASH].sort(),
);
});
test("a native transaction that moved ETH keeps its row beside the token row", () => {
// An undecoded call (no method name) carrying ETH that also emitted
// a token transfer: two real movements, so two rows.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
valueGwei: 250000000,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
});
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
// delete this row and lose a real ETH movement, so the decision is made
// on rawAmount as a BigInt.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.0000",
exactValue: "0.0000000005",
rawAmount: "500000000",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
"500000000",
);
});
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const sentLeg = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const receivedLeg = tokenTx({
hash: HASH,
from: ROUTER,
to: VICTIM,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("WETH");
expect(merged[0].exactValue).toBe("0.25");
// The user's own address and the contract called are preserved.
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(ROUTER);
expect(merged[0].directionLabel).toBe("Swap");
});
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const firstSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const secondSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [firstSent, secondSent]);
expect(merged).toHaveLength(1);
// With no received leg the display amount comes from the first sent
// leg, and a later sent leg does not overwrite it.
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
expect(merged[0].holders).toBe(3500000);
});
test("a contract call carrying ETH plus a token transfer stays one row", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "swapExactETHForTokens",
valueGwei: 250000000,
});
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [received]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
// The ETH leg is still visible as the row's native quantity.
expect(merged[0].valueGwei).toBe(250000000);
});
test("an approve keeps its row and survives the filters", () => {
const approve = contractCallTx({ hash: HASH });
const merged = mergeTransactions([approve], []);
expect(merged).toEqual([approve]);
expect(filterTransactions(merged, filters()).transactions).toEqual([
approve,
]);
});
test("a contract creation keeps its row", () => {
const creation = nativeTx({
hash: HASH,
from: VICTIM,
to: "",
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([creation], [])).toEqual([creation]);
});
test("a native self-send keeps its single row", () => {
const selfSend = nativeTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
});
test("a token self-send yields one row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(VICTIM);
});
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
const native = erc20CallTx({ hash: HASH });
const usdc = tokenTx({ hash: HASH });
const weth = tokenTx({
hash: HASH,
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [usdc, weth]);
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
test("rows are sorted by block number, newest first", () => {
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
const merged = mergeTransactions([older, newer], []);
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
});
test("the entries handed in are never mutated", () => {
const native = contractCallTx({ hash: HASH, method: "execute" });
const token = tokenTx({ hash: HASH });
const before = JSON.stringify([native, token]);
mergeTransactions([native], [token]);
expect(JSON.stringify([native, token])).toBe(before);
});
});
// ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers. (The cross-address merge Home performs lives in
@@ -1293,12 +886,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
// 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 () => {
// 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 () => {
const hash = "0x" + "5".repeat(64);
respondWith(
[
@@ -1331,15 +925,14 @@ describe("fetchRecentTransactions merge and dedup", () => {
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
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.
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.
const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1);
expect(kept[0].symbol).toBe("USDC");
expect(kept).toHaveLength(2);
});
test("entries are sorted by block number descending and capped at count", async () => {

View File

@@ -5,7 +5,6 @@ const {
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
@@ -119,19 +118,6 @@ describe("validateTransfer, native ETH", () => {
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
});
test("rejects a negative amount", () => {
// A negative amount parses to a perfectly good bigint, so neither
// balance comparison can fire: both are trivially false against it.
// Left unblocked it clears the screen and then dies at encode time.
const r = validateTransfer(
eth({ amount: "-1", ethBalance: "1.0", feeWei: 861000000000000n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(eth({ amount: "-0.000000000000000001" })),
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
@@ -196,13 +182,6 @@ describe("validateTransfer, ERC-20", () => {
).toEqual([CODES.FEE_UNAVAILABLE]);
});
test("rejects a negative token amount", () => {
const r = validateTransfer(
erc20({ amount: "-0.5", feeWei: 861000000000000n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
@@ -266,46 +245,6 @@ describe("feeReserveWei", () => {
});
});
// The display counterpart of the reserve: what the transaction is expected to
// cost. Shown alongside the reserve so the screen neither contradicts the gate
// nor quotes the user roughly double what they will pay.
describe("feeEstimateWei", () => {
const type2 = {
gasPrice: 21n * GWEI,
maxFeePerGas: 41n * GWEI,
maxPriorityFeePerGas: 1n * GWEI,
};
test("estimates gasLimit * gasPrice, below the reserve", () => {
expect(feeEstimateWei(GAS_LIMIT, type2)).toBe(441000000000000n);
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
expect(feeEstimateWei(GAS_LIMIT, type2)).toBeLessThan(
feeReserveWei(GAS_LIMIT, type2),
);
});
test("equals the reserve when the network has no type-2 pricing", () => {
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
expect(feeEstimateWei(GAS_LIMIT, legacy)).toBe(
feeReserveWei(GAS_LIMIT, legacy),
);
});
test("falls back to maxFeePerGas when there is no gasPrice", () => {
const noLegacy = { gasPrice: null, maxFeePerGas: 41n * GWEI };
expect(feeEstimateWei(GAS_LIMIT, noLegacy)).toBe(
feeReserveWei(GAS_LIMIT, noLegacy),
);
});
test("returns null on the same unusable inputs as the reserve", () => {
expect(feeEstimateWei(GAS_LIMIT, {})).toBe(null);
expect(feeEstimateWei(GAS_LIMIT, null)).toBe(null);
expect(feeEstimateWei(GAS_LIMIT, { gasPrice: -1n })).toBe(null);
expect(feeEstimateWei(21000, type2)).toBe(null);
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out.

View File

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

View File

@@ -1,6 +1,4 @@
// 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).
// Tests for the DEBUG build flag as it gates mnemonic generation.
//
// 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
@@ -94,317 +92,3 @@ 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]);
});
});