Compare commits
1 Commits
ef82c62912
...
ce81596100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce81596100 |
@@ -1,6 +1,3 @@
|
|||||||
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
|
|
||||||
# build-info stamping and the Dockerfile runs `make build`, so excluding it
|
|
||||||
# would make every built extension report commitHash "unknown".
|
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -11,7 +11,7 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@yarn install --frozen-lockfile
|
@yarn install
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -31,13 +31,10 @@ list exists to detect symbol spoofing attacks and improve UX.
|
|||||||
```bash
|
```bash
|
||||||
git clone https://git.eeqj.de/sneak/autistmask.git
|
git clone https://git.eeqj.de/sneak/autistmask.git
|
||||||
cd autistmask
|
cd autistmask
|
||||||
make setup
|
make install
|
||||||
make build
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
|
|
||||||
the lockfile and installs the git pre-commit hook.
|
|
||||||
|
|
||||||
Load the extension:
|
Load the extension:
|
||||||
|
|
||||||
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
|
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
|
||||||
@@ -100,19 +97,6 @@ provide:
|
|||||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
|
|
||||||
The Makefile shims to those. It also carries a few targets that have no
|
|
||||||
`script/` counterpart and are Makefile-only conveniences:
|
|
||||||
|
|
||||||
- `make install` — `yarn install --frozen-lockfile` on its own, without the rest
|
|
||||||
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
|
|
||||||
silently rewritten. Use `make setup` for a fresh clone.
|
|
||||||
- `make hooks` — shims to `script/install-precommit`
|
|
||||||
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
|
|
||||||
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
|
|
||||||
[Debug Builds](#debug-builds))
|
|
||||||
- `make clean` — remove `dist/`
|
|
||||||
- `make dev` — build in watch mode
|
|
||||||
|
|
||||||
## End-to-End Tests
|
## End-to-End Tests
|
||||||
|
|
||||||
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
||||||
@@ -575,26 +559,19 @@ screen, including ExportPrivKey, falls back to Home.
|
|||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses)
|
- Amount: value + symbol (USD in parentheses)
|
||||||
- Your balance: value + symbol (USD in parentheses)
|
- Your balance: value + symbol (USD in parentheses)
|
||||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
- Maximum network fee: "Estimating..." then the ETH amount (USD in
|
||||||
fetched async. The first line is what the transfer is expected to cost,
|
parentheses) or "Unable to estimate", fetched async. This is the
|
||||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
`gasLimit * maxFeePerGas` reserve the node requires, which is also what
|
||||||
`gasLimit * maxFeePerGas` reserve the node requires, which is what the
|
the balance check gates on; the transaction typically costs less once the
|
||||||
balance check gates on. The second line is omitted on a network with no
|
base fee is settled
|
||||||
type-2 pricing, where the two are the same number, but its space is
|
|
||||||
reserved either way
|
|
||||||
- Warnings: inline warnings from the local checks (scam address, self-send)
|
- Warnings: inline warnings from the local checks (scam address, self-send)
|
||||||
plus four reserved warning boxes made visible by the async checks —
|
plus four reserved warning boxes made visible by the async checks —
|
||||||
recipient with no transaction history, recipient is a contract, burn
|
recipient with no transaction history, recipient is a contract, burn
|
||||||
address, and an Etherscan phishing/scam label
|
address, and an Etherscan phishing/scam label
|
||||||
- Errors (insufficient balance), plus three reserved error boxes — the
|
- Errors (insufficient balance)
|
||||||
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
|
|
||||||
- Password: an inline field on this screen, not a modal, with its own error
|
- Password: an inline field on this screen, not a modal, with its own error
|
||||||
line
|
line
|
||||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
- "Sign & Send" button (disabled if errors)
|
||||||
estimate is pending or unavailable)
|
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
|
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
|
||||||
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
|
- "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
|
it. AutistMask hides transactions below a configurable dust threshold
|
||||||
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
|
(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
|
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`
|
about. The threshold is user-configurable in Settings.
|
||||||
hides nothing, exactly as clearing the checkbox does.
|
|
||||||
|
|
||||||
- **User-configurable**: All of the above filters (known symbol verification,
|
- **User-configurable**: All of the above filters (known symbol verification,
|
||||||
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
||||||
@@ -1200,8 +1176,8 @@ Currently supported:
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- [x] Tests for mnemonic generation and address derivation
|
- [ ] Tests for mnemonic generation and address derivation
|
||||||
- [x] Tests for xpub derivation and child address generation
|
- [ ] Tests for xpub derivation and child address generation
|
||||||
- [ ] Test on Firefox (Manifest V2)
|
- [ ] Test on Firefox (Manifest V2)
|
||||||
|
|
||||||
### Scam List
|
### Scam List
|
||||||
|
|||||||
26
TODO.md
26
TODO.md
@@ -49,24 +49,6 @@ undefined identifiers, which is how
|
|||||||
transaction, with the arithmetic in a pure, unit-tested
|
transaction, with the arithmetic in a pure, unit-tested
|
||||||
`src/shared/txValidation.js`
|
`src/shared/txValidation.js`
|
||||||
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
|
([#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
|
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
|
||||||
attribution, token-display rule, navigation model
|
attribution, token-display rule, navigation model
|
||||||
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
|
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
|
||||||
@@ -76,18 +58,10 @@ undefined identifiers, which is how
|
|||||||
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
||||||
all five network destinations documented, password/Settings/Add Wallet
|
all five network destinations documented, password/Settings/Add Wallet
|
||||||
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
||||||
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
|
|
||||||
of trusting the persisted flag, so a profile already saved inconsistent no
|
|
||||||
longer stays broken on every load
|
|
||||||
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
|
|
||||||
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
|
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
|
||||||
remaining wallets, the selection only moves when it was deleted, and the
|
remaining wallets, the selection only moves when it was deleted, and the
|
||||||
active-address change is broadcast to connected sites
|
active-address change is broadcast to connected sites
|
||||||
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
||||||
- 2026-08-11: One row per on-chain value movement in transaction history: the
|
|
||||||
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
|
|
||||||
a plain ERC-20 transfer absorbed into its token row
|
|
||||||
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
|
|
||||||
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
||||||
on `next`, with Status and Next Step refreshed
|
on `next`, with Status and Next Step refreshed
|
||||||
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
||||||
|
|||||||
17
build.js
17
build.js
@@ -29,12 +29,6 @@ function repoRelative(p) {
|
|||||||
// reports every input that contributed to an output in the metafile, which is
|
// reports every input that contributed to an output in the metafile, which is
|
||||||
// the authoritative answer to "is constants.js in this bundle" — unlike
|
// the authoritative answer to "is constants.js in this bundle" — unlike
|
||||||
// searching the minified text, it does not depend on what survived minification.
|
// searching the minified text, it does not depend on what survived minification.
|
||||||
//
|
|
||||||
// The ".js" filter below is the only place that assumption lives:
|
|
||||||
// script/verify-build searches every file and symlink under dist/ for a
|
|
||||||
// marker, without filtering by extension, and hard-fails if it cannot walk the
|
|
||||||
// whole tree, so a bundle emitted under some other extension fails there as
|
|
||||||
// unlisted rather than escaping both checks at once.
|
|
||||||
function outputsContainingAuditedModule(metafile) {
|
function outputsContainingAuditedModule(metafile) {
|
||||||
return Object.entries(metafile.outputs)
|
return Object.entries(metafile.outputs)
|
||||||
.filter(([outFile, info]) => {
|
.filter(([outFile, info]) => {
|
||||||
@@ -121,17 +115,8 @@ async function build() {
|
|||||||
// build that never gets around to writing one cannot be verified against
|
// build that never gets around to writing one cannot be verified against
|
||||||
// a stale list.
|
// a stale list.
|
||||||
fs.rmSync(BUNDLE_MANIFEST, { force: true });
|
fs.rmSync(BUNDLE_MANIFEST, { force: true });
|
||||||
// The locally installed binary, not `npx` — npx silently fetches from the
|
|
||||||
// registry when the binary is absent, which is an unpinned network fetch
|
|
||||||
// in the middle of a build.
|
|
||||||
const tailwindBin = path.join(
|
|
||||||
__dirname,
|
|
||||||
"node_modules",
|
|
||||||
".bin",
|
|
||||||
"tailwindcss",
|
|
||||||
);
|
|
||||||
execSync(
|
execSync(
|
||||||
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
|
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -265,10 +265,8 @@ The confirmation screen shows:
|
|||||||
- **From and To addresses** with identicons and Etherscan links
|
- **From and To addresses** with identicons and Etherscan links
|
||||||
- **Amount** with USD estimate
|
- **Amount** with USD estimate
|
||||||
- **Your current balance** with USD estimate
|
- **Your current balance** with USD estimate
|
||||||
- **Network fee** — what the transfer is expected to cost, in ETH with a USD
|
- **Maximum network fee** in ETH with USD estimate — the reserve the network
|
||||||
estimate, and below it the larger amount reserved until it confirms. The
|
requires, which the balance check gates on; the transaction usually costs less
|
||||||
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
|
|
||||||
- **Warnings** if the recipient is a contract, a burn address, one of your own
|
- **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
|
addresses, on the bundled scam-address list, or labelled as a phisher on
|
||||||
Etherscan
|
Etherscan
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit",
|
"test": "jest --forceExit",
|
||||||
"test:verbose": "jest --forceExit --verbose",
|
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "prettier --check .",
|
"lint": "prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Running tests..."
|
echo "Running tests..."
|
||||||
timeout 30 yarn run test 2>&1 || {
|
timeout 30 yarn run test 2>&1
|
||||||
echo "--- Rerunning with --verbose for details ---"
|
|
||||||
timeout 30 yarn run test:verbose 2>&1 || true
|
|
||||||
# Always fail: the first run already proved the tests are broken, so a
|
|
||||||
# flaky pass on the rerun must not turn the build green.
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -34,51 +34,16 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
|
|
||||||
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
|
|
||||||
# file could not be read) is not an answer at all, and must not be reported as
|
|
||||||
# "no marker" — that would blame the bundle for a permissions or I/O fault.
|
|
||||||
has_marker() {
|
has_marker() {
|
||||||
_hm_status=0
|
grep -q -F "$1" "$2" 2>/dev/null
|
||||||
grep -q -F -e "$1" -- "$2" || _hm_status=$?
|
|
||||||
case "$_hm_status" in
|
|
||||||
0) return 0 ;;
|
|
||||||
1) return 1 ;;
|
|
||||||
*)
|
|
||||||
fail "grep exited $_hm_status reading $2, so the file could not be
|
|
||||||
searched and its DEBUG state was not checked at all. That is a permissions
|
|
||||||
or I/O fault on the artifact, not a change in the emitted output. Refusing
|
|
||||||
to report success."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# Does the manifest list the path $1, as a whole line? Same discipline as
|
|
||||||
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
|
|
||||||
# manifest could not be read and is not an answer at all. Without this, an
|
|
||||||
# unreadable manifest reads as "this file is not listed" and every emitted
|
|
||||||
# bundle gets reported as an unlisted one.
|
|
||||||
is_listed() {
|
|
||||||
_il_status=0
|
|
||||||
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
|
|
||||||
case "$_il_status" in
|
|
||||||
0) return 0 ;;
|
|
||||||
1) return 1 ;;
|
|
||||||
*)
|
|
||||||
fail "grep exited $_il_status reading $MANIFEST, so it could not be
|
|
||||||
searched and nothing was established about which bundles it lists. That is
|
|
||||||
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
|
|
||||||
to report success."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
|
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
|
||||||
# present. Both means the ternary in constants.js was never folded, which is
|
# present. Both means the ternary in constants.js was never folded, which is
|
||||||
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
|
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
|
||||||
# DEBUG stops being known at build time. Neither means we are reading output
|
# DEBUG stops being known at build time and the debug branch is live again.
|
||||||
# we do not understand. Both are hard failures; neither is ever treated as
|
# Neither means we are reading output we do not understand. Both are hard
|
||||||
# absence of a problem.
|
# failures; neither is ever treated as absence of a problem.
|
||||||
read_marker() {
|
read_marker() {
|
||||||
_file="$1"
|
_file="$1"
|
||||||
_on=no
|
_on=no
|
||||||
@@ -87,14 +52,9 @@ read_marker() {
|
|||||||
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
|
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
|
||||||
|
|
||||||
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
|
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
|
||||||
fail "$_file carries both debug markers, so DEBUG was not resolved at
|
fail "$_file carries both debug markers, so the build-time DEBUG value
|
||||||
build time: the ternary in src/shared/constants.js survived into the
|
was never resolved and the debug branch is still live. Check that build.js
|
||||||
emitted output. This does not mean the debug branch is live in this
|
still defines __BUILD_DEBUG__."
|
||||||
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
|
|
||||||
context, so DEBUG evaluates to false at runtime. It does mean the
|
|
||||||
release/debug distinction is no longer enforced at build time, and which
|
|
||||||
way that fallback happens to evaluate is then an accident a refactor can
|
|
||||||
flip. Check that build.js still defines __BUILD_DEBUG__."
|
|
||||||
fi
|
fi
|
||||||
if [ "$_on" = no ] && [ "$_off" = no ]; then
|
if [ "$_on" = no ] && [ "$_off" = no ]; then
|
||||||
fail "$_file carries no debug marker, so its DEBUG state cannot be
|
fail "$_file carries no debug marker, so its DEBUG state cannot be
|
||||||
@@ -110,43 +70,13 @@ read_marker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# The manifest says which bundles must carry a marker. This says no other
|
# The manifest says which bundles must carry a marker. This says no other
|
||||||
# emitted file may carry one, which catches a manifest that has gone stale
|
# emitted bundle may carry one, which catches a manifest that has gone stale
|
||||||
# or short rather than trusting whatever it happens to list.
|
# or short rather than trusting whatever it happens to list.
|
||||||
#
|
|
||||||
# Deliberately unfiltered by extension. build.js selects manifest entries with
|
|
||||||
# an endsWith(".js") test; repeating that literal here would mean a bundle
|
|
||||||
# emitted under some other extension escaped the manifest AND this check at
|
|
||||||
# once, which is the correlated blind spot the two-source design exists to
|
|
||||||
# avoid. Every file under dist/ is searched, so build.js's filter is the only
|
|
||||||
# place the assumption lives and this check is what catches it being wrong.
|
|
||||||
#
|
|
||||||
# That claim only holds if the walk is exhaustive, so two things are enforced
|
|
||||||
# here rather than assumed:
|
|
||||||
#
|
|
||||||
# - find's exit status is checked. A subtree it cannot descend is reported on
|
|
||||||
# stderr and then simply missing from the listing, so an unchecked status
|
|
||||||
# turns "could not look" into "nothing was there" — the same conflation
|
|
||||||
# has_marker exists to prevent. The status cannot be read off a pipeline
|
|
||||||
# ending in sort, so the sort is a separate step.
|
|
||||||
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
|
|
||||||
# reachable under an unlisted path in dist/ is a stale manifest whether the
|
|
||||||
# path is a link or a file, and grep reads through the link. A link that
|
|
||||||
# cannot be read through — dangling, or pointing at a directory — fails
|
|
||||||
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
|
|
||||||
# build emits neither, so their DEBUG state is unproven, not fine.
|
|
||||||
check_unlisted_bundles() {
|
check_unlisted_bundles() {
|
||||||
_find_status=0
|
_listing="$(find dist -type f -name '*.js' | sort)"
|
||||||
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
|
|
||||||
[ "$_find_status" -eq 0 ] ||
|
|
||||||
fail "find exited $_find_status enumerating dist/, so part of the tree
|
|
||||||
was never walked and nothing was established about the files in it. Any
|
|
||||||
unlisted bundle there went unchecked. That is a permissions or I/O fault on
|
|
||||||
the artifact, not a stale manifest. Refusing to report success."
|
|
||||||
_listing="$(printf '%s\n' "$_listing" | sort)"
|
|
||||||
|
|
||||||
while read -r _file; do
|
while read -r _file; do
|
||||||
[ -n "$_file" ] || continue
|
[ -n "$_file" ] || continue
|
||||||
if is_listed "$_file"; then
|
if grep -q -x -F "$_file" "$MANIFEST"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if has_marker "$MARKER_ON" "$_file" ||
|
if has_marker "$MARKER_ON" "$_file" ||
|
||||||
@@ -183,18 +113,12 @@ main() {
|
|||||||
fail "$MANIFEST is empty, so no emitted bundle was found to contain
|
fail "$MANIFEST is empty, so no emitted bundle was found to contain
|
||||||
src/shared/constants.js. That is never correct, so it is a failure and not
|
src/shared/constants.js. That is never correct, so it is a failure and not
|
||||||
a pass."
|
a pass."
|
||||||
[ -r "$MANIFEST" ] ||
|
|
||||||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
|
|
||||||
permissions or I/O fault, not a pass."
|
|
||||||
|
|
||||||
count=0
|
count=0
|
||||||
while read -r file; do
|
while read -r file; do
|
||||||
[ -n "$file" ] || continue
|
[ -n "$file" ] || continue
|
||||||
[ -f "$file" ] ||
|
[ -f "$file" ] ||
|
||||||
fail "$MANIFEST lists $file, which does not exist."
|
fail "$MANIFEST lists $file, which does not exist."
|
||||||
[ -s "$file" ] ||
|
|
||||||
fail "$MANIFEST lists $file, which is empty. An empty bundle
|
|
||||||
carries no marker and proves nothing, so this is a failure and not a pass."
|
|
||||||
read_marker "$file"
|
read_marker "$file"
|
||||||
[ "$MARKER" = "$expected" ] ||
|
[ "$MARKER" = "$expected" ] ||
|
||||||
fail "$file is $MARKER but this build expects $expected."
|
fail "$file is $MARKER but this build expects $expected."
|
||||||
|
|||||||
@@ -582,18 +582,10 @@
|
|||||||
<div id="confirm-balance" class="text-xs"></div>
|
<div id="confirm-balance" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
||||||
<div class="text-xs text-muted mb-1">Network fee</div>
|
<div class="text-xs text-muted mb-1">
|
||||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
Maximum network fee
|
||||||
<!-- 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>
|
</div>
|
||||||
|
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="confirm-warnings"
|
id="confirm-warnings"
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const {
|
|||||||
FEE_KNOWN,
|
FEE_KNOWN,
|
||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
} = require("../../shared/txValidation");
|
} = require("../../shared/txValidation");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -184,7 +183,6 @@ function show(txInfo) {
|
|||||||
// Gas estimate — show placeholder then fetch async
|
// Gas estimate — show placeholder then fetch async
|
||||||
$("confirm-fee").style.visibility = "visible";
|
$("confirm-fee").style.visibility = "visible";
|
||||||
$("confirm-fee-amount").textContent = "Estimating...";
|
$("confirm-fee-amount").textContent = "Estimating...";
|
||||||
setVisible("confirm-fee-reserve", false);
|
|
||||||
state.viewData = { pendingTx: txInfo };
|
state.viewData = { pendingTx: txInfo };
|
||||||
showView("confirm-tx");
|
showView("confirm-tx");
|
||||||
attachCopyHandlers("view-confirm-tx");
|
attachCopyHandlers("view-confirm-tx");
|
||||||
@@ -287,14 +285,6 @@ function setVisible(id, visible) {
|
|||||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
$(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) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl);
|
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
|
// What the node will require to be reserved, which is what both the
|
||||||
// must be: the send pins no fee fields, so it is broadcast as a
|
// gate and the displayed figure must be: the send pins no fee fields,
|
||||||
// type-2 transaction priced at maxFeePerGas.
|
// so it is broadcast as a type-2 transaction priced at maxFeePerGas.
|
||||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||||
if (gasCostWei === null) {
|
if (gasCostWei === null) {
|
||||||
throw new Error("no usable gas price from the provider");
|
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
|
// 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.
|
// flight; a stale fee must not reach the screen or the balance check.
|
||||||
if (pendingTx !== txInfo) return;
|
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 ethPrice = getPrice("ETH");
|
||||||
const usd = (wei) =>
|
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
||||||
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
feeStatus = FEE_KNOWN;
|
feeStatus = FEE_KNOWN;
|
||||||
feeWei = gasCostWei;
|
feeWei = gasCostWei;
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
@@ -360,7 +335,6 @@ async function estimateGas(txInfo) {
|
|||||||
log.errorf("gas estimation failed:", e.message);
|
log.errorf("gas estimation failed:", e.message);
|
||||||
if (pendingTx !== txInfo) return;
|
if (pendingTx !== txInfo) return;
|
||||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||||
setVisible("confirm-fee-reserve", false);
|
|
||||||
feeStatus = FEE_UNAVAILABLE;
|
feeStatus = FEE_UNAVAILABLE;
|
||||||
feeWei = null;
|
feeWei = null;
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
|
|||||||
@@ -304,17 +304,11 @@ function init(ctx) {
|
|||||||
|
|
||||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||||
$("settings-dust-threshold").addEventListener("change", async () => {
|
$("settings-dust-threshold").addEventListener("change", async () => {
|
||||||
const raw = $("settings-dust-threshold").value.trim();
|
const val = parseInt($("settings-dust-threshold").value, 10);
|
||||||
const val = Number(raw);
|
if (!isNaN(val) && val >= 0) {
|
||||||
// 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) {
|
|
||||||
state.dustThresholdGwei = val;
|
state.dustThresholdGwei = val;
|
||||||
await saveState();
|
await saveState();
|
||||||
}
|
}
|
||||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
||||||
|
|||||||
@@ -84,11 +84,8 @@ async function loadState() {
|
|||||||
const result = await storageApi.get("autistmask");
|
const result = await storageApi.get("autistmask");
|
||||||
if (result.autistmask) {
|
if (result.autistmask) {
|
||||||
const saved = result.autistmask;
|
const saved = result.autistmask;
|
||||||
|
state.hasWallet = saved.hasWallet;
|
||||||
state.wallets = saved.wallets || [];
|
state.wallets = saved.wallets || [];
|
||||||
// Derived, never read from storage: a profile persisted with the flag
|
|
||||||
// out of step with the wallet list would otherwise stay broken on
|
|
||||||
// every load. Nothing depends on the two disagreeing.
|
|
||||||
state.hasWallet = state.wallets.length > 0;
|
|
||||||
state.trackedTokens = saved.trackedTokens || [];
|
state.trackedTokens = saved.trackedTokens || [];
|
||||||
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||||
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ const { formatEther, formatUnits } = require("ethers");
|
|||||||
const { log, debugFetch } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
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) {
|
function formatTxValue(val) {
|
||||||
const parts = val.split(".");
|
const parts = val.split(".");
|
||||||
if (parts.length === 1) return val + ".0000";
|
if (parts.length === 1) return val + ".0000";
|
||||||
@@ -38,10 +30,10 @@ function parseTx(tx, addrLower) {
|
|||||||
let exactValue = formatEther(rawWei);
|
let exactValue = formatEther(rawWei);
|
||||||
let rawAmount = rawWei;
|
let rawAmount = rawWei;
|
||||||
let rawUnit = "wei";
|
let rawUnit = "wei";
|
||||||
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
|
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||||
if (toIsContract && method && method !== "transfer") {
|
if (toIsContract && method && method !== "transfer") {
|
||||||
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
|
||||||
if (token) {
|
if (token) {
|
||||||
symbol = token.symbol;
|
symbol = token.symbol;
|
||||||
}
|
}
|
||||||
@@ -95,8 +87,7 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
const to = tt.to?.hash || "";
|
const to = tt.to?.hash || "";
|
||||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||||
const rawVal = tt.total?.value || "0";
|
const rawVal = tt.total?.value || "0";
|
||||||
const direction =
|
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||||
normalizeAddress(from) === addrLower ? "sent" : "received";
|
|
||||||
const sym = tt.token?.symbol || "?";
|
const sym = tt.token?.symbol || "?";
|
||||||
return {
|
return {
|
||||||
hash: tt.transaction_hash,
|
hash: tt.transaction_hash,
|
||||||
@@ -113,95 +104,18 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
direction: direction,
|
direction: direction,
|
||||||
directionLabel: direction === "sent" ? "Sent" : "Received",
|
directionLabel: direction === "sent" ? "Sent" : "Received",
|
||||||
isError: false,
|
isError: false,
|
||||||
contractAddress: normalizeAddress(
|
contractAddress: (
|
||||||
tt.token?.address_hash || tt.token?.address || "",
|
tt.token?.address_hash ||
|
||||||
),
|
tt.token?.address ||
|
||||||
|
""
|
||||||
|
).toLowerCase(),
|
||||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
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) {
|
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||||
log.debugf("fetchRecentTransactions", address);
|
log.debugf("fetchRecentTransactions", address);
|
||||||
const addrLower = normalizeAddress(address);
|
const addrLower = address.toLowerCase();
|
||||||
|
|
||||||
const [txResp, ttResp] = await Promise.all([
|
const [txResp, ttResp] = await Promise.all([
|
||||||
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
|
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
|
||||||
@@ -231,11 +145,53 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
|||||||
const txJson = txResp.ok ? await txResp.json() : {};
|
const txJson = txResp.ok ? await txResp.json() : {};
|
||||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||||
|
|
||||||
const txs = mergeTransactions(
|
const txsByHash = new Map();
|
||||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
|
||||||
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
for (const tx of txJson.items || []) {
|
||||||
|
txsByHash.set(tx.hash, parseTx(tx, addrLower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a token transfer shares a hash with a normal tx, the normal tx
|
||||||
|
// is the contract call (0 ETH) and the token transfer has the real
|
||||||
|
// amount and symbol. For contract calls (swaps), a single transaction
|
||||||
|
// can produce multiple token transfers (input, intermediates, output).
|
||||||
|
// We consolidate these into the original tx entry using the token
|
||||||
|
// transfer where the user *receives* tokens (the swap output), so
|
||||||
|
// the transaction list shows the final result rather than confusing
|
||||||
|
// intermediate hops. We preserve the original tx's from/to so the
|
||||||
|
// user sees their own address, not a router or Permit2 contract.
|
||||||
|
for (const tt of ttJson.items || []) {
|
||||||
|
const parsed = parseTokenTransfer(tt, addrLower);
|
||||||
|
const existing = txsByHash.get(parsed.hash);
|
||||||
|
if (existing && existing.direction === "contract") {
|
||||||
|
// For contract calls (swaps), consolidate into the original
|
||||||
|
// tx entry. Prefer the "received" transfer (swap output)
|
||||||
|
// for the display amount. If no received transfer exists,
|
||||||
|
// fall back to the first "sent" transfer (swap input).
|
||||||
|
const isReceived = parsed.direction === "received";
|
||||||
|
const needsAmount = !existing.exactValue;
|
||||||
|
if (isReceived || needsAmount) {
|
||||||
|
existing.value = parsed.value;
|
||||||
|
existing.exactValue = parsed.exactValue;
|
||||||
|
existing.rawAmount = parsed.rawAmount;
|
||||||
|
existing.rawUnit = parsed.rawUnit;
|
||||||
|
existing.symbol = parsed.symbol;
|
||||||
|
existing.contractAddress = parsed.contractAddress;
|
||||||
|
existing.holders = parsed.holders;
|
||||||
|
}
|
||||||
|
// Keep the original tx's from/to (the user's address and the
|
||||||
|
// contract they called), not the token transfer's from/to
|
||||||
|
// which may be a router or Permit2 contract.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-contract token transfers get their own entries.
|
||||||
|
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
|
||||||
|
txsByHash.set(ttKey, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const txs = [...txsByHash.values()];
|
||||||
|
|
||||||
|
txs.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||||
const result = txs.slice(0, count);
|
const result = txs.slice(0, count);
|
||||||
log.debugf("fetchRecentTransactions done, count:", result.length);
|
log.debugf("fetchRecentTransactions done, count:", result.length);
|
||||||
return result;
|
return result;
|
||||||
@@ -250,38 +206,34 @@ function isSpoofedSymbol(tx) {
|
|||||||
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
||||||
const legit = KNOWN_SYMBOLS.get(symbol);
|
const legit = KNOWN_SYMBOLS.get(symbol);
|
||||||
if (legit === null) return true; // "ETH" as ERC-20 is always fake
|
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,
|
// Pure filter function. Takes raw transactions and filter settings,
|
||||||
// returns { transactions, newFraudContracts }.
|
// returns { transactions, newFraudContracts }.
|
||||||
function filterTransactions(txs, filters = {}) {
|
function filterTransactions(txs, filters = {}) {
|
||||||
const fraudSet = new Set(
|
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 newFraud = [];
|
||||||
const filtered = [];
|
const filtered = [];
|
||||||
|
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
const contract = normalizeAddress(tx.contractAddress);
|
|
||||||
|
|
||||||
// Always filter spoofed known symbols and record the fraud contract
|
// Always filter spoofed known symbols and record the fraud contract
|
||||||
if (isSpoofedSymbol(tx)) {
|
if (isSpoofedSymbol(tx)) {
|
||||||
if (contract && !fraudSet.has(contract)) {
|
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
|
||||||
fraudSet.add(contract);
|
fraudSet.add(tx.contractAddress);
|
||||||
newFraud.push(contract);
|
newFraud.push(tx.contractAddress);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter fraud contracts if setting is on
|
// Filter fraud contracts if setting is on
|
||||||
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
|
if (
|
||||||
|
filters.hideFraudContracts &&
|
||||||
|
tx.contractAddress &&
|
||||||
|
fraudSet.has(tx.contractAddress)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +254,7 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
filters.hideDustTransactions &&
|
filters.hideDustTransactions &&
|
||||||
!tx.isContractCall &&
|
!tx.isContractCall &&
|
||||||
tx.valueGwei !== null &&
|
tx.valueGwei !== null &&
|
||||||
tx.valueGwei < dustThresholdGwei
|
tx.valueGwei < (filters.dustThresholdGwei || 100000)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -313,8 +265,4 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
return { transactions: filtered, newFraudContracts: newFraud };
|
return { transactions: filtered, newFraudContracts: newFraud };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = { fetchRecentTransactions, filterTransactions };
|
||||||
fetchRecentTransactions,
|
|
||||||
filterTransactions,
|
|
||||||
mergeTransactions,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const FEE_KNOWN = "known";
|
|||||||
const FEE_UNAVAILABLE = "unavailable";
|
const FEE_UNAVAILABLE = "unavailable";
|
||||||
|
|
||||||
const CODES = {
|
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",
|
AMOUNT_INVALID: "amount-invalid",
|
||||||
// ERC-20: the token amount exceeds the token balance.
|
// ERC-20: the token amount exceeds the token balance.
|
||||||
INSUFFICIENT_TOKEN: "insufficient-token",
|
INSUFFICIENT_TOKEN: "insufficient-token",
|
||||||
@@ -58,21 +58,6 @@ function feeReserveWei(gasLimit, feeData) {
|
|||||||
return gasLimit * price;
|
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
|
// 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
|
// 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.
|
// 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.
|
// Validate a pending transfer against the balances that must cover it.
|
||||||
//
|
//
|
||||||
// isErc20 — token transfer rather than a native ETH transfer
|
// isErc20 — token transfer rather than a native ETH transfer
|
||||||
// amount — human decimal string being sent, non-negative. Anything
|
// amount — human decimal string being sent
|
||||||
// else, a negative value included, is an unusable amount
|
|
||||||
// rather than an amount that passes every comparison.
|
|
||||||
// ethBalance — human decimal string, the sender's ETH balance
|
// ethBalance — human decimal string, the sender's ETH balance
|
||||||
// tokenBalance — human decimal string, the sender's token balance
|
// tokenBalance — human decimal string, the sender's token balance
|
||||||
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
||||||
@@ -116,10 +99,7 @@ function validateTransfer({
|
|||||||
const amountFp = toFixedPoint(amount);
|
const amountFp = toFixedPoint(amount);
|
||||||
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
||||||
|
|
||||||
// A negative amount parses to a valid bigint, so every comparison below
|
if (amountFp === null) {
|
||||||
// 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) {
|
|
||||||
codes.push(CODES.AMOUNT_INVALID);
|
codes.push(CODES.AMOUNT_INVALID);
|
||||||
return { canSend: false, codes };
|
return { canSend: false, codes };
|
||||||
}
|
}
|
||||||
@@ -165,7 +145,6 @@ module.exports = {
|
|||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
SCALE_DECIMALS,
|
SCALE_DECIMALS,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -36,7 +36,6 @@ global.chrome = { storage: { local: {} } };
|
|||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
filterTransactions,
|
filterTransactions,
|
||||||
mergeTransactions,
|
|
||||||
} = require("../src/shared/transactions");
|
} = require("../src/shared/transactions");
|
||||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||||
const { debugFetch } = require("../src/shared/log");
|
const { debugFetch } = require("../src/shared/log");
|
||||||
@@ -329,42 +328,18 @@ describe("known-symbol spoof verification", () => {
|
|||||||
expect(result.newFraudContracts).toEqual([]);
|
expect(result.newFraudContracts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression guard (#179): EIP-55 mixed case is a checksum over the
|
// Documents current behaviour, not desired behaviour: the spoof check
|
||||||
// address, not part of its identity, so the contract comparison must be
|
// compares tx.contractAddress against a lowercased known address with
|
||||||
// case-insensitive in both directions — a genuine token in any casing is
|
// ===, so a caller passing a checksummed address for a genuine token has
|
||||||
// genuine, and a spoof cannot escape detection by changing its casing.
|
// it treated as a spoof. In the app this cannot happen because
|
||||||
test("a genuine contract in all-lowercase form is not a spoof", () => {
|
// parseTokenTransfer lowercases, but the exported function is not
|
||||||
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
|
// defensive about it the way the blocklist check is.
|
||||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
|
||||||
});
|
const genuineButChecksummed = tokenTx({
|
||||||
|
|
||||||
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
|
|
||||||
const tx = tokenTx({
|
|
||||||
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||||
});
|
});
|
||||||
const result = filterTransactions([tx], filters());
|
const result = filterTransactions([genuineButChecksummed], 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());
|
|
||||||
expect(result.transactions).toEqual([]);
|
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
|
// 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(tx.holders).toBeNull();
|
||||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
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", () => {
|
describe("fraud contract blocklist", () => {
|
||||||
@@ -614,50 +574,16 @@ describe("dust threshold filtering", () => {
|
|||||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
|
// Documents current behaviour: the threshold is read as
|
||||||
// not an absent one. It used to be swallowed by `|| 100000`, so the one
|
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
|
||||||
// value a user would pick to see everything was the one that did not
|
// to 0 (the natural way to ask for no dust filtering while leaving the
|
||||||
// work.
|
// toggle on) silently gets the 100,000 gwei default instead.
|
||||||
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
|
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
|
||||||
const dust = dustOf(50);
|
const result = filterTransactions(
|
||||||
const zero = dustOf(0);
|
[dustOf(50)],
|
||||||
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],
|
|
||||||
filters({ dustThresholdGwei: 0 }),
|
filters({ dustThresholdGwei: 0 }),
|
||||||
);
|
);
|
||||||
const toggleOff = filterTransactions(
|
expect(result.transactions).toEqual([]);
|
||||||
[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([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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
|
// fetchRecentTransactions owns the per-address merge of normal transactions
|
||||||
// with ERC-20 transfers. (The cross-address merge Home performs lives in
|
// with ERC-20 transfers. (The cross-address merge Home performs lives in
|
||||||
@@ -1293,12 +886,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression guard for the duplicate-row bug: for a plain ERC-20
|
// Documents current behaviour: for a plain ERC-20 transfer the method is
|
||||||
// transfer the method is "transfer", so parseTx does not mark the entry
|
// "transfer", so parseTx does not mark the entry as a contract call in
|
||||||
// as a contract call in the display sense. The native side of that
|
// the display sense and the merge loop does not consolidate the token
|
||||||
// transaction moved no ETH and is represented by the token row, so it
|
// transfer into it. The result is two entries for one transaction: a
|
||||||
// must not survive the merge as a second, zero-value row.
|
// zero-value native row and the real token row. The zero-value row also
|
||||||
test("a plain ERC-20 transfer produces exactly one entry", async () => {
|
// escapes dust filtering because isContractCall is true.
|
||||||
|
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
|
||||||
const hash = "0x" + "5".repeat(64);
|
const hash = "0x" + "5".repeat(64);
|
||||||
respondWith(
|
respondWith(
|
||||||
[
|
[
|
||||||
@@ -1331,15 +925,14 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||||
expect(txs).toHaveLength(1);
|
expect(txs).toHaveLength(2);
|
||||||
expect(txs[0].symbol).toBe("USDC");
|
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||||
expect(txs[0].exactValue).toBe("1.0");
|
const nativeRow = txs.find((t) => t.symbol === "ETH");
|
||||||
expect(txs[0].direction).toBe("sent");
|
expect(nativeRow.exactValue).toBe("0.0");
|
||||||
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
|
expect(nativeRow.isContractCall).toBe(true);
|
||||||
// The surviving row is the token row, and the filters keep it.
|
// And the zero-value row is not removed by the dust filter.
|
||||||
const kept = filterTransactions(txs, filters()).transactions;
|
const kept = filterTransactions(txs, filters()).transactions;
|
||||||
expect(kept).toHaveLength(1);
|
expect(kept).toHaveLength(2);
|
||||||
expect(kept[0].symbol).toBe("USDC");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entries are sorted by block number descending and capped at count", async () => {
|
test("entries are sorted by block number descending and capped at count", async () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const {
|
|||||||
FEE_KNOWN,
|
FEE_KNOWN,
|
||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
} = require("../src/shared/txValidation");
|
} = require("../src/shared/txValidation");
|
||||||
@@ -119,19 +118,6 @@ describe("validateTransfer, native ETH", () => {
|
|||||||
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
|
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", () => {
|
test("treats a missing balance as zero, not as unlimited", () => {
|
||||||
const r = validateTransfer(eth({ ethBalance: undefined }));
|
const r = validateTransfer(eth({ ethBalance: undefined }));
|
||||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
|
||||||
@@ -196,13 +182,6 @@ describe("validateTransfer, ERC-20", () => {
|
|||||||
).toEqual([CODES.FEE_UNAVAILABLE]);
|
).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", () => {
|
test("treats a missing token balance as zero", () => {
|
||||||
const r = validateTransfer(erc20({ tokenBalance: undefined }));
|
const r = validateTransfer(erc20({ tokenBalance: undefined }));
|
||||||
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
|
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.
|
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
|
||||||
// Each of these previously returned { canSend: true, codes: [] } — counting no
|
// 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.
|
// fee at all, on a full-balance send, in the direction that lets money out.
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic
|
// Tests for the DEBUG build flag as it gates mnemonic generation.
|
||||||
// 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
|
// 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
|
// 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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user