Compare commits
1 Commits
ad34fa8699
...
2957601fcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2957601fcd |
@@ -1,6 +1,3 @@
|
||||
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
|
||||
# build-info stamping and the Dockerfile runs `make build`, so excluding it
|
||||
# would make every built extension report commitHash "unknown".
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
|
||||
2
Makefile
2
Makefile
@@ -11,7 +11,7 @@ setup:
|
||||
@script/setup
|
||||
|
||||
install:
|
||||
@yarn install --frozen-lockfile
|
||||
@yarn install
|
||||
|
||||
test:
|
||||
@script/test
|
||||
|
||||
81
README.md
81
README.md
@@ -31,13 +31,10 @@ list exists to detect symbol spoofing attacks and improve UX.
|
||||
```bash
|
||||
git clone https://git.eeqj.de/sneak/autistmask.git
|
||||
cd autistmask
|
||||
make setup
|
||||
make install
|
||||
make build
|
||||
```
|
||||
|
||||
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
|
||||
the lockfile and installs the git pre-commit hook.
|
||||
|
||||
Load the extension:
|
||||
|
||||
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
|
||||
@@ -100,19 +97,6 @@ provide:
|
||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||
- `script/install-precommit` — install the git pre-commit hook
|
||||
|
||||
The Makefile shims to those. It also carries a few targets that have no
|
||||
`script/` counterpart and are Makefile-only conveniences:
|
||||
|
||||
- `make install` — `yarn install --frozen-lockfile` on its own, without the rest
|
||||
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
|
||||
silently rewritten. Use `make setup` for a fresh clone.
|
||||
- `make hooks` — shims to `script/install-precommit`
|
||||
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
|
||||
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
|
||||
[Debug Builds](#debug-builds))
|
||||
- `make clean` — remove `dist/`
|
||||
- `make dev` — build in watch mode
|
||||
|
||||
## End-to-End Tests
|
||||
|
||||
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
||||
@@ -127,9 +111,8 @@ It covers popup load, wallet creation through the UI, the Add Token screen, the
|
||||
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
|
||||
— which wallet types are offered it, that it holds nothing before the password
|
||||
is accepted, that a wrong password reveals nothing, that leaving it by either
|
||||
route wipes it — including a leave taken while the decrypt is still running —
|
||||
and that reopening the popup does not land on it. All outbound network is
|
||||
intercepted at the browser level and served from fixtures in
|
||||
route wipes it, and that reopening the popup does not land on it. All outbound
|
||||
network is intercepted at the browser level and served from fixtures in
|
||||
`tests/e2e/network.js`, so the run is deterministic and fully offline;
|
||||
unrecognised outbound requests are reported as failures rather than silently
|
||||
allowed.
|
||||
@@ -366,32 +349,17 @@ The core hierarchy is **Wallets → Addresses**:
|
||||
address scan on import, but imported from an extended private key rather
|
||||
than a recovery phrase. It therefore has no recovery phrase to display or
|
||||
back up.
|
||||
- An **address** holds ETH and ERC-20 tokens.
|
||||
- An **address** holds ETH and any user-added ERC-20 tokens.
|
||||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||||
single address (key).
|
||||
|
||||
Which tokens an address shows is decided by `fetchTokenBalances()` in
|
||||
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
|
||||
tokens do appear without the user adding them. An ERC-20 is shown when its
|
||||
balance is nonzero and it is in the bundled top-250 token list, is tracked by
|
||||
the user, or has 1,000 or more holders; a token claiming a symbol from the
|
||||
bundled list from any other contract address is always dropped. That filter is
|
||||
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
|
||||
the transaction history and the send-screen token selector, not this list.
|
||||
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
|
||||
with zero balance" is on.
|
||||
|
||||
#### Navigation
|
||||
|
||||
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
||||
The user taps an address to see its detail view (full address, balance, tokens,
|
||||
send/receive). Navigation is a stack: each forward action pushes the current
|
||||
screen, and every view has a "Back" or "Cancel" button that pops back to it (see
|
||||
the Screen Map below). There is no hamburger menu and no persistent tab bar; the
|
||||
Settings gear in the title bar is the only global control. Two screens carry an
|
||||
in-screen control beyond that: AddWallet uses three tabs to select the import
|
||||
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
|
||||
behind a "···" menu.
|
||||
send/receive). Navigation is flat — every view has a "Back" or "Cancel" button
|
||||
that returns to the previous context. No deep nesting, no tabs, no hamburger
|
||||
menus.
|
||||
|
||||
### Screen Map
|
||||
|
||||
@@ -431,7 +399,7 @@ of it.
|
||||
- **When**: At least one wallet exists. This is the root screen.
|
||||
- **Elements**:
|
||||
- Active address ETH balance (large) + USD value in parentheses
|
||||
- "Total:" USD value across ETH and every token shown for the active address
|
||||
- "Total:" USD value across ETH and all tracked tokens of the active address
|
||||
- Active address (color dot, full address, etherscan link, tap to copy)
|
||||
- Send / Receive quick-action buttons, both acting on the active address
|
||||
- ETH/USD price display
|
||||
@@ -439,7 +407,7 @@ of it.
|
||||
button for HD and xprv wallets, then one block per address with "Address
|
||||
N" (bold when active), the ENS name if resolved, the full address, an
|
||||
`[info]` button, the address USD total, and a balance line for ETH and for
|
||||
each token shown for that address
|
||||
each tracked token
|
||||
- "Recent Transactions": up to 25 transactions merged across every address
|
||||
of every wallet, deduplicated by hash and filtered
|
||||
- "Add additional wallet..." link at bottom
|
||||
@@ -492,8 +460,8 @@ of it.
|
||||
- ENS name (if resolved, bold above the address)
|
||||
- Full address (color dot, etherscan link, tap to copy)
|
||||
- USD total for address
|
||||
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
|
||||
places, USD inline). Each balance row is clickable → **AddressToken**
|
||||
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
|
||||
Each balance row is clickable → **AddressToken**
|
||||
- Send / Receive / + Token buttons and a "···" menu button
|
||||
- "···" dropdown containing a single "Export Private Key" entry
|
||||
- Transaction list (with ENS resolution for counterparties)
|
||||
@@ -772,8 +740,7 @@ of it.
|
||||
- **Secret handling**: nothing is decrypted or written into the page until the
|
||||
password is accepted; the phrase is never stored in state, and it is wiped
|
||||
from the page whenever the screen is left by any route, including the Settings
|
||||
gear. A decrypt still running when the screen is left is discarded rather than
|
||||
written. The screen is not restorable, so reopening the popup lands on Home
|
||||
gear. The screen is not restorable, so reopening the popup lands on Home
|
||||
rather than back on the phrase.
|
||||
|
||||
#### DeleteWallet (`delete-wallet-confirm`)
|
||||
@@ -928,7 +895,7 @@ communicates with three external services to function as a wallet:
|
||||
What the extension does NOT do:
|
||||
|
||||
- No analytics or telemetry services
|
||||
- No token list APIs (the top-250 token list is bundled at build time)
|
||||
- No token list APIs (user adds tokens manually by contract address)
|
||||
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
||||
- No backend servers operated by the developer
|
||||
|
||||
@@ -1051,8 +1018,7 @@ hardcoded test phrase.
|
||||
- Add multiple addresses within an HD wallet
|
||||
- Manage multiple wallets simultaneously
|
||||
- View ETH balance per address
|
||||
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
|
||||
holders, and tokens the user adds by contract address)
|
||||
- View ERC-20 token balances (user adds token by contract address)
|
||||
- Send ETH to an address
|
||||
- Send ERC-20 tokens to an address
|
||||
- Receive ETH/tokens (display address, copy to clipboard, QR code)
|
||||
@@ -1198,8 +1164,7 @@ Currently supported:
|
||||
- Built in token swaps (use a DEX in the browser)
|
||||
- Analytics, telemetry, or tracking of any kind
|
||||
- Advertisements or promotions
|
||||
- Obscure token list auto-discovery — nothing outside the bundled list, the
|
||||
1,000-holder floor, and the tokens the user added by contract address
|
||||
- Obscure token list auto-discovery (user adds tokens manually)
|
||||
- We detect common/popular ERC20s in the basic case
|
||||
- Fiat on/off ramps
|
||||
- Extensive transaction decoding/parsing
|
||||
@@ -1256,18 +1221,14 @@ This repository includes data files from third-party projects that are not
|
||||
covered by the GPL-3.0 license above. These files, their copyright holders, and
|
||||
their licenses are:
|
||||
|
||||
| File | Source | Copyright | License |
|
||||
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
||||
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
||||
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
||||
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
||||
| File | Source | Copyright | License |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
||||
| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
||||
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
||||
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
||||
|
||||
The full license texts for these third-party files are included in the
|
||||
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
|
||||
link because the upstream is hosted under a competitor's organization name,
|
||||
which project policy keeps out of code and documentation; the vendored copy and
|
||||
the runtime refresh both come from that upstream, whose URL is the
|
||||
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
|
||||
[LICENSE](LICENSE) file.
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
21
TODO.md
21
TODO.md
@@ -48,37 +48,16 @@ undefined identifiers, which is how
|
||||
from the wallet row in Settings, wiped on leaving the screen and excluded from
|
||||
the views the popup can reopen onto
|
||||
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
|
||||
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
|
||||
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
|
||||
the Makefile-only targets documented in the README
|
||||
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
|
||||
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
|
||||
message now states what is and is not proven, an unreadable bundle is
|
||||
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
|
||||
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
|
||||
cannot enumerate `dist/`
|
||||
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
|
||||
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
|
||||
attribution, token-display rule, navigation model
|
||||
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
|
||||
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
|
||||
and transition re-verified against `src/popup/`
|
||||
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
|
||||
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
||||
all five network destinations documented, password/Settings/Add Wallet
|
||||
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
||||
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
|
||||
of trusting the persisted flag, so a profile already saved inconsistent no
|
||||
longer stays broken on every load
|
||||
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
|
||||
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
|
||||
remaining wallets, the selection only moves when it was deleted, and the
|
||||
active-address change is broadcast to connected sites
|
||||
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
||||
- 2026-08-11: One row per on-chain value movement in transaction history: the
|
||||
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
|
||||
a plain ERC-20 transfer absorbed into its token row
|
||||
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
|
||||
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
||||
on `next`, with Status and Next Step refreshed
|
||||
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
||||
|
||||
17
build.js
17
build.js
@@ -29,12 +29,6 @@ function repoRelative(p) {
|
||||
// reports every input that contributed to an output in the metafile, which is
|
||||
// the authoritative answer to "is constants.js in this bundle" — unlike
|
||||
// searching the minified text, it does not depend on what survived minification.
|
||||
//
|
||||
// The ".js" filter below is the only place that assumption lives:
|
||||
// script/verify-build searches every file and symlink under dist/ for a
|
||||
// marker, without filtering by extension, and hard-fails if it cannot walk the
|
||||
// whole tree, so a bundle emitted under some other extension fails there as
|
||||
// unlisted rather than escaping both checks at once.
|
||||
function outputsContainingAuditedModule(metafile) {
|
||||
return Object.entries(metafile.outputs)
|
||||
.filter(([outFile, info]) => {
|
||||
@@ -121,17 +115,8 @@ async function build() {
|
||||
// build that never gets around to writing one cannot be verified against
|
||||
// a stale list.
|
||||
fs.rmSync(BUNDLE_MANIFEST, { force: true });
|
||||
// The locally installed binary, not `npx` — npx silently fetches from the
|
||||
// registry when the binary is absent, which is an unpinned network fetch
|
||||
// in the middle of a build.
|
||||
const tailwindBin = path.join(
|
||||
__dirname,
|
||||
"node_modules",
|
||||
".bin",
|
||||
"tailwindcss",
|
||||
);
|
||||
execSync(
|
||||
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
|
||||
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --forceExit",
|
||||
"test:verbose": "jest --forceExit --verbose",
|
||||
"build": "node build.js",
|
||||
"lint": "prettier --check .",
|
||||
"fmt": "prettier --write .",
|
||||
|
||||
@@ -7,13 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
echo "Running tests..."
|
||||
timeout 30 yarn run test 2>&1 || {
|
||||
echo "--- Rerunning with --verbose for details ---"
|
||||
timeout 30 yarn run test:verbose 2>&1 || true
|
||||
# Always fail: the first run already proved the tests are broken, so a
|
||||
# flaky pass on the rerun must not turn the build green.
|
||||
exit 1
|
||||
}
|
||||
timeout 30 yarn run test 2>&1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -34,51 +34,16 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
|
||||
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
|
||||
# file could not be read) is not an answer at all, and must not be reported as
|
||||
# "no marker" — that would blame the bundle for a permissions or I/O fault.
|
||||
has_marker() {
|
||||
_hm_status=0
|
||||
grep -q -F -e "$1" -- "$2" || _hm_status=$?
|
||||
case "$_hm_status" in
|
||||
0) return 0 ;;
|
||||
1) return 1 ;;
|
||||
*)
|
||||
fail "grep exited $_hm_status reading $2, so the file could not be
|
||||
searched and its DEBUG state was not checked at all. That is a permissions
|
||||
or I/O fault on the artifact, not a change in the emitted output. Refusing
|
||||
to report success."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Does the manifest list the path $1, as a whole line? Same discipline as
|
||||
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
|
||||
# manifest could not be read and is not an answer at all. Without this, an
|
||||
# unreadable manifest reads as "this file is not listed" and every emitted
|
||||
# bundle gets reported as an unlisted one.
|
||||
is_listed() {
|
||||
_il_status=0
|
||||
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
|
||||
case "$_il_status" in
|
||||
0) return 0 ;;
|
||||
1) return 1 ;;
|
||||
*)
|
||||
fail "grep exited $_il_status reading $MANIFEST, so it could not be
|
||||
searched and nothing was established about which bundles it lists. That is
|
||||
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
|
||||
to report success."
|
||||
;;
|
||||
esac
|
||||
grep -q -F "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
|
||||
# present. Both means the ternary in constants.js was never folded, which is
|
||||
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
|
||||
# DEBUG stops being known at build time. Neither means we are reading output
|
||||
# we do not understand. Both are hard failures; neither is ever treated as
|
||||
# absence of a problem.
|
||||
# DEBUG stops being known at build time and the debug branch is live again.
|
||||
# Neither means we are reading output we do not understand. Both are hard
|
||||
# failures; neither is ever treated as absence of a problem.
|
||||
read_marker() {
|
||||
_file="$1"
|
||||
_on=no
|
||||
@@ -87,14 +52,9 @@ read_marker() {
|
||||
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
|
||||
|
||||
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
|
||||
fail "$_file carries both debug markers, so DEBUG was not resolved at
|
||||
build time: the ternary in src/shared/constants.js survived into the
|
||||
emitted output. This does not mean the debug branch is live in this
|
||||
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
|
||||
context, so DEBUG evaluates to false at runtime. It does mean the
|
||||
release/debug distinction is no longer enforced at build time, and which
|
||||
way that fallback happens to evaluate is then an accident a refactor can
|
||||
flip. Check that build.js still defines __BUILD_DEBUG__."
|
||||
fail "$_file carries both debug markers, so the build-time DEBUG value
|
||||
was never resolved and the debug branch is still live. Check that build.js
|
||||
still defines __BUILD_DEBUG__."
|
||||
fi
|
||||
if [ "$_on" = no ] && [ "$_off" = no ]; then
|
||||
fail "$_file carries no debug marker, so its DEBUG state cannot be
|
||||
@@ -110,43 +70,13 @@ read_marker() {
|
||||
}
|
||||
|
||||
# The manifest says which bundles must carry a marker. This says no other
|
||||
# emitted file may carry one, which catches a manifest that has gone stale
|
||||
# emitted bundle may carry one, which catches a manifest that has gone stale
|
||||
# or short rather than trusting whatever it happens to list.
|
||||
#
|
||||
# Deliberately unfiltered by extension. build.js selects manifest entries with
|
||||
# an endsWith(".js") test; repeating that literal here would mean a bundle
|
||||
# emitted under some other extension escaped the manifest AND this check at
|
||||
# once, which is the correlated blind spot the two-source design exists to
|
||||
# avoid. Every file under dist/ is searched, so build.js's filter is the only
|
||||
# place the assumption lives and this check is what catches it being wrong.
|
||||
#
|
||||
# That claim only holds if the walk is exhaustive, so two things are enforced
|
||||
# here rather than assumed:
|
||||
#
|
||||
# - find's exit status is checked. A subtree it cannot descend is reported on
|
||||
# stderr and then simply missing from the listing, so an unchecked status
|
||||
# turns "could not look" into "nothing was there" — the same conflation
|
||||
# has_marker exists to prevent. The status cannot be read off a pipeline
|
||||
# ending in sort, so the sort is a separate step.
|
||||
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
|
||||
# reachable under an unlisted path in dist/ is a stale manifest whether the
|
||||
# path is a link or a file, and grep reads through the link. A link that
|
||||
# cannot be read through — dangling, or pointing at a directory — fails
|
||||
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
|
||||
# build emits neither, so their DEBUG state is unproven, not fine.
|
||||
check_unlisted_bundles() {
|
||||
_find_status=0
|
||||
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
|
||||
[ "$_find_status" -eq 0 ] ||
|
||||
fail "find exited $_find_status enumerating dist/, so part of the tree
|
||||
was never walked and nothing was established about the files in it. Any
|
||||
unlisted bundle there went unchecked. That is a permissions or I/O fault on
|
||||
the artifact, not a stale manifest. Refusing to report success."
|
||||
_listing="$(printf '%s\n' "$_listing" | sort)"
|
||||
|
||||
_listing="$(find dist -type f -name '*.js' | sort)"
|
||||
while read -r _file; do
|
||||
[ -n "$_file" ] || continue
|
||||
if is_listed "$_file"; then
|
||||
if grep -q -x -F "$_file" "$MANIFEST"; then
|
||||
continue
|
||||
fi
|
||||
if has_marker "$MARKER_ON" "$_file" ||
|
||||
@@ -183,18 +113,12 @@ main() {
|
||||
fail "$MANIFEST is empty, so no emitted bundle was found to contain
|
||||
src/shared/constants.js. That is never correct, so it is a failure and not
|
||||
a pass."
|
||||
[ -r "$MANIFEST" ] ||
|
||||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
|
||||
permissions or I/O fault, not a pass."
|
||||
|
||||
count=0
|
||||
while read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
[ -f "$file" ] ||
|
||||
fail "$MANIFEST lists $file, which does not exist."
|
||||
[ -s "$file" ] ||
|
||||
fail "$MANIFEST lists $file, which is empty. An empty bundle
|
||||
carries no marker and proves nothing, so this is a failure and not a pass."
|
||||
read_marker "$file"
|
||||
[ "$MARKER" = "$expected" ] ||
|
||||
fail "$file is $MARKER but this build expects $expected."
|
||||
|
||||
@@ -123,8 +123,7 @@ function renderWalletListSettings() {
|
||||
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
// No pushCurrentView() here: showPhrase.show() refuses
|
||||
// non-HD wallets and pushes only when it navigates.
|
||||
pushCurrentView();
|
||||
showPhrase.show(idx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
// 1. Only an HD wallet reaches this screen (walletHasRecoveryPhrase).
|
||||
// 2. Nothing is decrypted, and nothing is written into the DOM, until
|
||||
// decryptWithPassword has accepted the password.
|
||||
// 3. Leaving the screen by any path wipes it, via the onViewLeave hook,
|
||||
// and a decrypt still in flight when that happens is discarded
|
||||
// instead of written (revealGeneration).
|
||||
// 3. Leaving the screen by any path wipes it, via the onViewLeave hook.
|
||||
// 4. The phrase never reaches the logger. This module deliberately does
|
||||
// not import src/shared/log.js, and the failed-decrypt path reports a
|
||||
// fixed sentence rather than the caught error.
|
||||
@@ -24,7 +22,6 @@ const {
|
||||
flashCopyFeedback,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
pushCurrentView,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
@@ -34,24 +31,6 @@ const VIEW = "show-phrase";
|
||||
|
||||
let walletIndex = null;
|
||||
|
||||
// Bumped by every clear(), which is what leaving the screen runs. reveal()
|
||||
// captures it before awaiting the decrypt and refuses to touch the DOM if
|
||||
// it has moved: a decrypt still in flight when the screen is left would
|
||||
// otherwise write the phrase *after* the wipe, with nothing scheduled to
|
||||
// wipe it again, leaving it in the hidden view for the life of the popup.
|
||||
let revealGeneration = 0;
|
||||
|
||||
// True only if the reveal that captured `generation` is still the live one:
|
||||
// the screen has not been left, cleared, or re-entered for another wallet
|
||||
// since it started.
|
||||
function isCurrentReveal(generation) {
|
||||
return (
|
||||
generation === revealGeneration &&
|
||||
walletIndex !== null &&
|
||||
state.currentView === VIEW
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
$("show-phrase-flash").textContent = message;
|
||||
$("show-phrase-flash").style.visibility = "visible";
|
||||
@@ -61,7 +40,6 @@ function fail(message) {
|
||||
// call when nothing was ever revealed, and safe to call twice.
|
||||
function clear() {
|
||||
walletIndex = null;
|
||||
revealGeneration += 1;
|
||||
$("show-phrase-value").textContent = "";
|
||||
$("show-phrase-password").value = "";
|
||||
$("show-phrase-result").classList.add("hidden");
|
||||
@@ -80,10 +58,6 @@ function show(walletIdx) {
|
||||
walletIndex = walletIdx;
|
||||
$("show-phrase-wallet-name").textContent =
|
||||
wallet.name || "Wallet " + (walletIdx + 1);
|
||||
// Pushed here rather than by the caller: this function can return
|
||||
// without navigating, and a push that happened anyway would leave an
|
||||
// entry on the stack that no screen transition matches.
|
||||
pushCurrentView();
|
||||
showView(VIEW);
|
||||
}
|
||||
|
||||
@@ -106,16 +80,11 @@ async function reveal() {
|
||||
const btn = $("btn-show-phrase-reveal");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
const generation = revealGeneration;
|
||||
try {
|
||||
const phrase = await decryptWithPassword(
|
||||
wallet.encryptedSecret,
|
||||
password,
|
||||
);
|
||||
// The only suspension point in this view, and the only place a
|
||||
// secret is written: if the screen was left while the decrypt ran,
|
||||
// the wipe has already happened and this write must not land.
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
$("show-phrase-password").value = "";
|
||||
$("show-phrase-password-section").classList.add("hidden");
|
||||
$("show-phrase-value").textContent = phrase;
|
||||
@@ -123,7 +92,6 @@ async function reveal() {
|
||||
$("show-phrase-flash").textContent = "";
|
||||
$("show-phrase-flash").style.visibility = "hidden";
|
||||
} catch {
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
// Deliberately not the caught error: the message is fixed so that
|
||||
// nothing derived from the ciphertext or the attempt can surface.
|
||||
fail("That password is not correct. Please try again.");
|
||||
|
||||
@@ -84,11 +84,8 @@ async function loadState() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
if (result.autistmask) {
|
||||
const saved = result.autistmask;
|
||||
state.hasWallet = saved.hasWallet;
|
||||
state.wallets = saved.wallets || [];
|
||||
// Derived, never read from storage: a profile persisted with the flag
|
||||
// out of step with the wallet list would otherwise stay broken on
|
||||
// every load. Nothing depends on the two disagreeing.
|
||||
state.hasWallet = state.wallets.length > 0;
|
||||
state.trackedTokens = saved.trackedTokens || [];
|
||||
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||
|
||||
@@ -113,85 +113,6 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
};
|
||||
}
|
||||
|
||||
// True when a parsed native entry moved no ETH. Contract-call entries have
|
||||
// their amount fields blanked by parseTx, so they are never judged here.
|
||||
function movedNoEther(tx) {
|
||||
if (tx.direction === "contract") return false;
|
||||
return BigInt(tx.rawAmount || "0") === BigInt(0);
|
||||
}
|
||||
|
||||
// Merge parsed normal transactions with parsed ERC-20 token transfers into
|
||||
// one row per distinct value movement. Pure: it reads only its arguments
|
||||
// and returns a new list sorted newest block first.
|
||||
//
|
||||
// The merge key is the transaction hash for the native entry and
|
||||
// hash + token contract for each token transfer, so:
|
||||
//
|
||||
// - A display-level contract call (a swap and friends, direction
|
||||
// "contract") absorbs every token leg of its hash into the single
|
||||
// native entry, because the legs are hops of one operation rather
|
||||
// than separate movements the user made.
|
||||
// - Otherwise each distinct token contract in the transaction keeps its
|
||||
// own row, so a hash carrying several genuine transfers stays several
|
||||
// rows.
|
||||
// - The native entry of such a transaction is dropped when it moved no
|
||||
// ETH and at least one token transfer shares its hash: that entry is
|
||||
// the ERC-20 call itself, already represented by the token row. A
|
||||
// native entry that moved ETH survives alongside the token rows, since
|
||||
// the ETH and the tokens are two real movements, and a zero-value
|
||||
// native transaction with no token transfer on its hash survives too.
|
||||
function mergeTransactions(txs, tokenTransfers) {
|
||||
const byKey = new Map();
|
||||
|
||||
// Entries are copied so consolidation never writes through to the
|
||||
// caller's objects.
|
||||
for (const tx of txs) {
|
||||
byKey.set(tx.hash, { ...tx });
|
||||
}
|
||||
|
||||
const absorbedHashes = new Set();
|
||||
|
||||
for (const parsed of tokenTransfers) {
|
||||
const existing = byKey.get(parsed.hash);
|
||||
if (existing && existing.direction === "contract") {
|
||||
// For contract calls (swaps), consolidate into the original
|
||||
// tx entry. Prefer the "received" transfer (swap output)
|
||||
// for the display amount. If no received transfer exists,
|
||||
// fall back to the first "sent" transfer (swap input).
|
||||
const isReceived = parsed.direction === "received";
|
||||
const needsAmount = !existing.exactValue;
|
||||
if (isReceived || needsAmount) {
|
||||
existing.value = parsed.value;
|
||||
existing.exactValue = parsed.exactValue;
|
||||
existing.rawAmount = parsed.rawAmount;
|
||||
existing.rawUnit = parsed.rawUnit;
|
||||
existing.symbol = parsed.symbol;
|
||||
existing.contractAddress = parsed.contractAddress;
|
||||
existing.holders = parsed.holders;
|
||||
}
|
||||
// Keep the original tx's from/to (the user's address and the
|
||||
// contract they called), not the token transfer's from/to
|
||||
// which may be a router or Permit2 contract.
|
||||
continue;
|
||||
}
|
||||
if (existing && movedNoEther(existing)) {
|
||||
absorbedHashes.add(parsed.hash);
|
||||
}
|
||||
// Every other token transfer gets its own entry.
|
||||
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
|
||||
...parsed,
|
||||
});
|
||||
}
|
||||
|
||||
for (const hash of absorbedHashes) {
|
||||
byKey.delete(hash);
|
||||
}
|
||||
|
||||
const merged = [...byKey.values()];
|
||||
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = address.toLowerCase();
|
||||
@@ -224,11 +145,53 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
const txJson = txResp.ok ? await txResp.json() : {};
|
||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||
|
||||
const txs = mergeTransactions(
|
||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
||||
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
||||
);
|
||||
const txsByHash = new Map();
|
||||
|
||||
for (const tx of txJson.items || []) {
|
||||
txsByHash.set(tx.hash, parseTx(tx, addrLower));
|
||||
}
|
||||
|
||||
// When a token transfer shares a hash with a normal tx, the normal tx
|
||||
// is the contract call (0 ETH) and the token transfer has the real
|
||||
// amount and symbol. For contract calls (swaps), a single transaction
|
||||
// can produce multiple token transfers (input, intermediates, output).
|
||||
// We consolidate these into the original tx entry using the token
|
||||
// transfer where the user *receives* tokens (the swap output), so
|
||||
// the transaction list shows the final result rather than confusing
|
||||
// intermediate hops. We preserve the original tx's from/to so the
|
||||
// user sees their own address, not a router or Permit2 contract.
|
||||
for (const tt of ttJson.items || []) {
|
||||
const parsed = parseTokenTransfer(tt, addrLower);
|
||||
const existing = txsByHash.get(parsed.hash);
|
||||
if (existing && existing.direction === "contract") {
|
||||
// For contract calls (swaps), consolidate into the original
|
||||
// tx entry. Prefer the "received" transfer (swap output)
|
||||
// for the display amount. If no received transfer exists,
|
||||
// fall back to the first "sent" transfer (swap input).
|
||||
const isReceived = parsed.direction === "received";
|
||||
const needsAmount = !existing.exactValue;
|
||||
if (isReceived || needsAmount) {
|
||||
existing.value = parsed.value;
|
||||
existing.exactValue = parsed.exactValue;
|
||||
existing.rawAmount = parsed.rawAmount;
|
||||
existing.rawUnit = parsed.rawUnit;
|
||||
existing.symbol = parsed.symbol;
|
||||
existing.contractAddress = parsed.contractAddress;
|
||||
existing.holders = parsed.holders;
|
||||
}
|
||||
// Keep the original tx's from/to (the user's address and the
|
||||
// contract they called), not the token transfer's from/to
|
||||
// which may be a router or Permit2 contract.
|
||||
continue;
|
||||
}
|
||||
// Non-contract token transfers get their own entries.
|
||||
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
|
||||
txsByHash.set(ttKey, parsed);
|
||||
}
|
||||
|
||||
const txs = [...txsByHash.values()];
|
||||
|
||||
txs.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||
const result = txs.slice(0, count);
|
||||
log.debugf("fetchRecentTransactions done, count:", result.length);
|
||||
return result;
|
||||
@@ -302,8 +265,4 @@ function filterTransactions(txs, filters = {}) {
|
||||
return { transactions: filtered, newFraudContracts: newFraud };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
mergeTransactions,
|
||||
};
|
||||
module.exports = { fetchRecentTransactions, filterTransactions };
|
||||
|
||||
@@ -35,10 +35,6 @@ function assert(cond, message) {
|
||||
if (!cond) throw new Error(message);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function withTimeout(promise, name) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
@@ -307,58 +303,6 @@ test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
||||
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
||||
});
|
||||
|
||||
// The same leave, but taken while the decrypt is still running. Both
|
||||
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
|
||||
// handler up to the await, the gear then runs the leave — and the wipe with
|
||||
// it — to completion, and the decrypt's continuation resumes afterwards.
|
||||
// Without a liveness check that continuation writes the phrase into the
|
||||
// hidden screen after the wipe, and nothing is left to wipe it again.
|
||||
//
|
||||
// A human cannot produce this interleaving by hand once libsodium's wasm is
|
||||
// warm, because crypto_pwhash is synchronous and the only suspension point
|
||||
// is a microtask; the window a user can actually hit is a still-pending
|
||||
// sodium.ready on the first vault use of a page load. Forcing it here is
|
||||
// the only way to test the guard deterministically.
|
||||
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
|
||||
await openPhraseScreen(env.page);
|
||||
await env.page.fill("#show-phrase-password", PASSWORD);
|
||||
await env.page.evaluate(() => {
|
||||
document.getElementById("btn-show-phrase-reveal").click();
|
||||
document.getElementById("btn-settings").click();
|
||||
});
|
||||
await visible(env.page, "#view-settings");
|
||||
|
||||
// The Reveal button is disabled for exactly the duration of the
|
||||
// decrypt and re-enabled in the same continuation that would have
|
||||
// written the phrase, so waiting for it to come back is a precise
|
||||
// "the decrypt has settled and its handler has finished" signal
|
||||
// rather than a guess at a duration.
|
||||
await env.page.waitForFunction(
|
||||
() => !document.getElementById("btn-show-phrase-reveal").disabled,
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(2000);
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
// Printed on every run, pass or fail: "the phrase is not there" is
|
||||
// worth more as a measurement than as a silent assertion, and the
|
||||
// same line read from a build without the guard is what this test
|
||||
// exists to prevent.
|
||||
console.log(
|
||||
"# probe: len=" +
|
||||
st.value.length +
|
||||
" equalsPhrase=" +
|
||||
(st.value === env.phrase) +
|
||||
" resultHidden=" +
|
||||
st.resultHidden +
|
||||
" viewHidden=" +
|
||||
st.viewHidden,
|
||||
);
|
||||
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
||||
assertWiped(st, env.phrase, "after leaving mid-decrypt");
|
||||
});
|
||||
|
||||
// Closing and reopening the page rather than reloading it: that is what
|
||||
// the toolbar popup actually does, and the persisted currentView is
|
||||
// "show-phrase" at the moment it happens, which is precisely the state
|
||||
|
||||
@@ -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 {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
mergeTransactions,
|
||||
} = require("../src/shared/transactions");
|
||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||
const { debugFetch } = require("../src/shared/log");
|
||||
@@ -686,339 +685,6 @@ describe("legitimate transactions are never filtered", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeTransactions is the pure core of the merge: it takes parsed native
|
||||
// entries and parsed token transfers and decides how many rows one on-chain
|
||||
// transaction becomes. One transaction is one row per distinct value
|
||||
// movement, so the native side of a plain ERC-20 transfer must not survive
|
||||
// next to its token row (the duplicate-row bug), while a hash that really
|
||||
// did move several things must keep a row for each.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A native entry as parseTx produces it for a decoded contract call: the
|
||||
// amount fields are blanked and direction is "contract".
|
||||
function contractCallTx(overrides = {}) {
|
||||
return nativeTx({
|
||||
from: VICTIM,
|
||||
to: USDC_CONTRACT,
|
||||
value: "",
|
||||
exactValue: "",
|
||||
rawAmount: "",
|
||||
rawUnit: "",
|
||||
valueGwei: 0,
|
||||
direction: "contract",
|
||||
directionLabel: "Approve",
|
||||
isContractCall: true,
|
||||
method: "approve",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
|
||||
// token contract, no ETH, and method "transfer", which is exactly why it is
|
||||
// not marked as a display-level contract call.
|
||||
function erc20CallTx(overrides = {}) {
|
||||
return nativeTx({
|
||||
from: VICTIM,
|
||||
to: USDC_CONTRACT,
|
||||
value: "0.0000",
|
||||
exactValue: "0.0",
|
||||
rawAmount: "0",
|
||||
valueGwei: 0,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
isContractCall: true,
|
||||
method: "transfer",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("mergeTransactions: one row per value movement", () => {
|
||||
const HASH = "0x" + "d".repeat(64);
|
||||
const OTHER_HASH = "0x" + "e".repeat(64);
|
||||
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
|
||||
|
||||
test("a plain ERC-20 transfer yields one row, the token row", () => {
|
||||
const native = erc20CallTx({ hash: HASH });
|
||||
const token = tokenTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ORDINARY_PEER,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
|
||||
const merged = mergeTransactions([native], [token]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].symbol).toBe("USDC");
|
||||
expect(merged[0].exactValue).toBe("1500.5");
|
||||
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
|
||||
});
|
||||
|
||||
test("an ETH-only transfer keeps its row unchanged", () => {
|
||||
const merged = mergeTransactions([legitimateEthSend()], []);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toEqual(legitimateEthSend());
|
||||
});
|
||||
|
||||
test("a genuine zero-value native transaction is still displayed", () => {
|
||||
const zero = nativeTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ORDINARY_PEER,
|
||||
value: "0.0000",
|
||||
exactValue: "0.0",
|
||||
rawAmount: "0",
|
||||
valueGwei: 0,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
|
||||
const merged = mergeTransactions([zero], []);
|
||||
expect(merged).toEqual([zero]);
|
||||
});
|
||||
|
||||
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
|
||||
const zero = erc20CallTx({ hash: HASH });
|
||||
const unrelated = tokenTx({ hash: OTHER_HASH });
|
||||
|
||||
const merged = mergeTransactions([zero], [unrelated]);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.map((t) => t.hash).sort()).toEqual(
|
||||
[HASH, OTHER_HASH].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("a native transaction that moved ETH keeps its row beside the token row", () => {
|
||||
// An undecoded call (no method name) carrying ETH that also emitted
|
||||
// a token transfer: two real movements, so two rows.
|
||||
const native = nativeTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ROUTER,
|
||||
value: "0.2500",
|
||||
exactValue: "0.25",
|
||||
rawAmount: "250000000000000000",
|
||||
valueGwei: 250000000,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
isContractCall: true,
|
||||
});
|
||||
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
|
||||
|
||||
const merged = mergeTransactions([native], [token]);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||
});
|
||||
|
||||
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
|
||||
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
|
||||
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
|
||||
// delete this row and lose a real ETH movement, so the decision is made
|
||||
// on rawAmount as a BigInt.
|
||||
const native = nativeTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ROUTER,
|
||||
value: "0.0000",
|
||||
exactValue: "0.0000000005",
|
||||
rawAmount: "500000000",
|
||||
valueGwei: 0,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
isContractCall: true,
|
||||
});
|
||||
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
|
||||
|
||||
const merged = mergeTransactions([native], [token]);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
|
||||
"500000000",
|
||||
);
|
||||
});
|
||||
|
||||
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
|
||||
const native = contractCallTx({
|
||||
hash: HASH,
|
||||
to: ROUTER,
|
||||
directionLabel: "Swap",
|
||||
method: "execute",
|
||||
});
|
||||
const sentLeg = tokenTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ROUTER,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
const receivedLeg = tokenTx({
|
||||
hash: HASH,
|
||||
from: ROUTER,
|
||||
to: VICTIM,
|
||||
value: "0.2500",
|
||||
exactValue: "0.25",
|
||||
rawAmount: "250000000000000000",
|
||||
rawUnit: "WETH base units (10^-18)",
|
||||
symbol: "WETH",
|
||||
contractAddress: WETH_CONTRACT,
|
||||
holders: 850000,
|
||||
});
|
||||
|
||||
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].symbol).toBe("WETH");
|
||||
expect(merged[0].exactValue).toBe("0.25");
|
||||
// The user's own address and the contract called are preserved.
|
||||
expect(merged[0].from).toBe(VICTIM);
|
||||
expect(merged[0].to).toBe(ROUTER);
|
||||
expect(merged[0].directionLabel).toBe("Swap");
|
||||
});
|
||||
|
||||
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
|
||||
const native = contractCallTx({
|
||||
hash: HASH,
|
||||
to: ROUTER,
|
||||
directionLabel: "Swap",
|
||||
method: "execute",
|
||||
});
|
||||
const firstSent = tokenTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ROUTER,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
const secondSent = tokenTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: ROUTER,
|
||||
value: "0.2500",
|
||||
exactValue: "0.25",
|
||||
rawAmount: "250000000000000000",
|
||||
rawUnit: "WETH base units (10^-18)",
|
||||
symbol: "WETH",
|
||||
contractAddress: WETH_CONTRACT,
|
||||
holders: 850000,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
|
||||
const merged = mergeTransactions([native], [firstSent, secondSent]);
|
||||
expect(merged).toHaveLength(1);
|
||||
// With no received leg the display amount comes from the first sent
|
||||
// leg, and a later sent leg does not overwrite it.
|
||||
expect(merged[0].symbol).toBe("USDC");
|
||||
expect(merged[0].exactValue).toBe("1500.5");
|
||||
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
|
||||
expect(merged[0].holders).toBe(3500000);
|
||||
});
|
||||
|
||||
test("a contract call carrying ETH plus a token transfer stays one row", () => {
|
||||
const native = contractCallTx({
|
||||
hash: HASH,
|
||||
to: ROUTER,
|
||||
directionLabel: "Swap",
|
||||
method: "swapExactETHForTokens",
|
||||
valueGwei: 250000000,
|
||||
});
|
||||
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
|
||||
|
||||
const merged = mergeTransactions([native], [received]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].symbol).toBe("USDC");
|
||||
expect(merged[0].exactValue).toBe("1500.5");
|
||||
// The ETH leg is still visible as the row's native quantity.
|
||||
expect(merged[0].valueGwei).toBe(250000000);
|
||||
});
|
||||
|
||||
test("an approve keeps its row and survives the filters", () => {
|
||||
const approve = contractCallTx({ hash: HASH });
|
||||
|
||||
const merged = mergeTransactions([approve], []);
|
||||
expect(merged).toEqual([approve]);
|
||||
expect(filterTransactions(merged, filters()).transactions).toEqual([
|
||||
approve,
|
||||
]);
|
||||
});
|
||||
|
||||
test("a contract creation keeps its row", () => {
|
||||
const creation = nativeTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: "",
|
||||
value: "0.0000",
|
||||
exactValue: "0.0",
|
||||
rawAmount: "0",
|
||||
valueGwei: 0,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
|
||||
expect(mergeTransactions([creation], [])).toEqual([creation]);
|
||||
});
|
||||
|
||||
test("a native self-send keeps its single row", () => {
|
||||
const selfSend = nativeTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: VICTIM,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
|
||||
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
|
||||
});
|
||||
|
||||
test("a token self-send yields one row", () => {
|
||||
const native = erc20CallTx({ hash: HASH });
|
||||
const token = tokenTx({
|
||||
hash: HASH,
|
||||
from: VICTIM,
|
||||
to: VICTIM,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
});
|
||||
|
||||
const merged = mergeTransactions([native], [token]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].symbol).toBe("USDC");
|
||||
expect(merged[0].from).toBe(VICTIM);
|
||||
expect(merged[0].to).toBe(VICTIM);
|
||||
});
|
||||
|
||||
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
|
||||
const native = erc20CallTx({ hash: HASH });
|
||||
const usdc = tokenTx({ hash: HASH });
|
||||
const weth = tokenTx({
|
||||
hash: HASH,
|
||||
symbol: "WETH",
|
||||
contractAddress: WETH_CONTRACT,
|
||||
holders: 850000,
|
||||
});
|
||||
|
||||
const merged = mergeTransactions([native], [usdc, weth]);
|
||||
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||
});
|
||||
|
||||
test("rows are sorted by block number, newest first", () => {
|
||||
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
|
||||
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
|
||||
|
||||
const merged = mergeTransactions([older, newer], []);
|
||||
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
|
||||
});
|
||||
|
||||
test("the entries handed in are never mutated", () => {
|
||||
const native = contractCallTx({ hash: HASH, method: "execute" });
|
||||
const token = tokenTx({ hash: HASH });
|
||||
const before = JSON.stringify([native, token]);
|
||||
|
||||
mergeTransactions([native], [token]);
|
||||
expect(JSON.stringify([native, token])).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchRecentTransactions owns the per-address merge of normal transactions
|
||||
// with ERC-20 transfers. (The cross-address merge Home performs lives in
|
||||
@@ -1220,12 +886,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||
});
|
||||
|
||||
// Regression guard for the duplicate-row bug: for a plain ERC-20
|
||||
// transfer the method is "transfer", so parseTx does not mark the entry
|
||||
// as a contract call in the display sense. The native side of that
|
||||
// transaction moved no ETH and is represented by the token row, so it
|
||||
// must not survive the merge as a second, zero-value row.
|
||||
test("a plain ERC-20 transfer produces exactly one entry", async () => {
|
||||
// Documents current behaviour: for a plain ERC-20 transfer the method is
|
||||
// "transfer", so parseTx does not mark the entry as a contract call in
|
||||
// the display sense and the merge loop does not consolidate the token
|
||||
// transfer into it. The result is two entries for one transaction: a
|
||||
// zero-value native row and the real token row. The zero-value row also
|
||||
// escapes dust filtering because isContractCall is true.
|
||||
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
|
||||
const hash = "0x" + "5".repeat(64);
|
||||
respondWith(
|
||||
[
|
||||
@@ -1258,15 +925,14 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(txs).toHaveLength(1);
|
||||
expect(txs[0].symbol).toBe("USDC");
|
||||
expect(txs[0].exactValue).toBe("1.0");
|
||||
expect(txs[0].direction).toBe("sent");
|
||||
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
|
||||
// The surviving row is the token row, and the filters keep it.
|
||||
expect(txs).toHaveLength(2);
|
||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||
const nativeRow = txs.find((t) => t.symbol === "ETH");
|
||||
expect(nativeRow.exactValue).toBe("0.0");
|
||||
expect(nativeRow.isContractCall).toBe(true);
|
||||
// And the zero-value row is not removed by the dust filter.
|
||||
const kept = filterTransactions(txs, filters()).transactions;
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0].symbol).toBe("USDC");
|
||||
expect(kept).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("entries are sorted by block number descending and capped at count", async () => {
|
||||
|
||||
Reference in New Issue
Block a user