Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,54 @@ jobs:
- run: pnpm run format:check
- run: pnpm build
- run: pnpm test
bundle-size:
name: Bundle size check
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

- name: Check bundle sizes
run: pnpm bundle:check

- name: Generate bundle stats
run: pnpm bundle:visualize

- name: Upload bundle stats
if: always()
uses: actions/upload-artifact@v4
with:
name: bundle-stats-${{ github.sha }}
path: dist/stats/
retention-days: 30

- name: Comment sizes on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
// Read BUNDLE_SIZE.md and post the table as a PR comment
const content = fs.readFileSync('BUNDLE_SIZE.md', 'utf8');
const table = content.match(/\| Entry.*?\n\|[-|]+\n([\s\S]*?)\n\n/)?.[0] ?? 'See BUNDLE_SIZE.md';
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Bundle sizes\n\n${table}\n\n[Full report](BUNDLE_SIZE.md)`,
});
30 changes: 30 additions & 0 deletions .github/workflows/leak-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Scan Leak

on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:

jobs:
scan-leak:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run test:leak
- name: Upload leak artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: scan-leak-artifacts
path: |
test/leak/artifacts/**
isolate-*.log
if-no-files-found: ignore
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
reference/
node_modules/
dist/
!dist/stats/
!dist/stats/*.html
*.tsbuildinfo
test/leak/artifacts/
test/leak/profiles/*.log
test/leak/profiles/*.cpuprofile
test/leak/profiles/*.heapsnapshot
test/leak/profiles/.clinic/
isolate-*.log
34 changes: 34 additions & 0 deletions BUNDLE_SIZE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Bundle Size Report

> Generated: 2026-05-31T20:41:05.624Z
> Tooling: esbuild 0.21.5, minified + gzip

| Entry | Minified (KB) | Gzip (KB) |
|---|---|---|
| `index` | 2.26 | 0.98 |
| `chains/evm` | 69.87 | 25.07 |
| `chains/stellar` | 40.92 | 17.15 |
| `chains/solana` | 44.21 | 18.05 |
| `chains/ckb` | 56.44 | 21.60 |

## Cross-import audit

> Intentional cross-imports (expected):
> - `chains/solana` re-uses `chains/stellar` scalar math (ed25519)
> - `chains/ckb` re-uses `chains/evm` key derivation (secp256k1)
>
> Unexpected cross-imports found during this audit:
> - (none)

## Before / After

| Entry | Before (gzip KB) | After (gzip KB) | Delta |
|---|---|---|---|
| `chains/stellar` | TBD | 17.15 | TBD |
| `chains/evm` | TBD | 25.07 | TBD |
| `chains/solana` | TBD | 18.05 | TBD |
| `chains/ckb` | TBD | 21.60 | TBD |
| `index` | TBD | 0.98 | TBD |

> Before values are populated after the first CI run on main.
> After values reflect post-optimization sizes in this PR.
69 changes: 69 additions & 0 deletions docs/scan-leak-debugging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Scan Leak Debugging

The scan leak harness exercises the EVM `scanAnnouncements` hot path with a synthetic
10,000-announcement dataset for 10,000 scan iterations. It samples RSS, heap, and external
memory, writes heap snapshots, and fails when RSS grows faster than the configured slope.

## Run the Leak Test

```sh
pnpm run test:leak
```

The script runs Node with `--expose-gc`, so the harness forces GC before samples and runs the
WeakRef retention check. Generated artifacts are written to `test/leak/artifacts/`.

Useful tuning knobs:

```sh
LEAK_ITERATIONS=10000 \
LEAK_ANNOUNCEMENTS=10000 \
LEAK_SAMPLE_INTERVAL=100 \
LEAK_SNAPSHOT_INTERVAL=2500 \
LEAK_MAX_RSS_SLOPE_KB=2 \
pnpm run test:leak
```

Set `LEAK_SNAPSHOT_INTERVAL=0` to disable heap snapshots for faster local loops.

## Profiling

Generate V8 profiler output:

```sh
pnpm run profile:scan:v8
```

This creates an `isolate-*.log` file. Convert it with Node's profiler tooling:

```sh
node --prof-process isolate-*.log > test/leak/profiles/v8-profile.txt
```

Run clinic.js doctor:

```sh
pnpm run profile:scan:clinic
```

The clinic command uses `pnpm dlx`, so it can download clinic when it is not installed locally.
Keep large raw profiler output out of git; commit only small summaries or representative HTML/PNG
when needed as profiling evidence.

## Interpreting Results

RSS is the resident set size: total memory held by the process. The harness computes a linear
regression slope in KB per scan iteration after dropping the first 10% of samples as warmup. A
stable scanner should have a near-flat RSS slope even if individual samples move up and down.

Heap snapshots are written with `v8.writeHeapSnapshot()`. Open them in Chrome DevTools Memory
panel and compare early versus late snapshots. Look for retained arrays, closures, listeners,
maps, timers, or worker resources that grow with iteration count.

Common scanner leak sources:

- Closures retaining announcement arrays or buffers after a scan completes.
- Event listener accumulation across scan calls.
- Unbounded browser or IndexedDB cache growth; the documented cap is 50 MB when that cache exists.
- Worker threads not terminating on success or error.
- Timers or unresolved promises keeping the event loop alive.
55 changes: 50 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,64 @@
"require": "./dist/chains/ckb/index.cjs"
}
},
"size-limit": [
{
"name": "index",
"path": "dist/index.js",
"limit": "1.03 KB",
"import": "{ Wraith, WraithAgent, Chain }"
},
{
"name": "chains/stellar",
"path": "dist/chains/stellar/index.js",
"limit": "18.01 KB",
"import": "{ deriveStealthKeys, generateStealthAddress, scanAnnouncements }"
},
{
"name": "chains/evm",
"path": "dist/chains/evm/index.js",
"limit": "26.32 KB",
"import": "{ deriveStealthKeys, generateStealthAddress, scanAnnouncements }"
},
{
"name": "chains/solana",
"path": "dist/chains/solana/index.js",
"limit": "18.95 KB",
"import": "{ deriveStealthKeys, generateStealthAddress, scanAnnouncements }"
},
{
"name": "chains/ckb",
"path": "dist/chains/ckb/index.js",
"limit": "22.68 KB",
"import": "{ deriveStealthKeys, generateStealthAddress, scanStealthCells }"
}
],
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"bundle:check": "pnpm build && size-limit",
"bundle:measure": "pnpm build && tsx scripts/measure-bundles.ts",
"bundle:visualize": "pnpm build && tsx scripts/visualize-bundles.ts",
"clean": "rm -rf dist",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
"prepare": "husky",
"profile:scan:clinic": "pnpm dlx clinic doctor -- node --expose-gc ./node_modules/vitest/vitest.mjs run test/leak/scan-leak.test.ts --pool=threads --poolOptions.threads.singleThread",
"profile:scan:v8": "node --expose-gc --prof ./node_modules/vitest/vitest.mjs run test/leak/scan-leak.test.ts --pool=threads --poolOptions.threads.singleThread",
"test": "vitest run --exclude test/leak/**",
"test:leak": "node --expose-gc ./node_modules/vitest/vitest.mjs run test/leak/scan-leak.test.ts --pool=threads --poolOptions.threads.singleThread",
"test:watch": "vitest"
},
"dependencies": {
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
"viem": "^2.23.0"
},
"peerDependencies": {
"@stellar/stellar-sdk": "^13.1.0",
"@solana/web3.js": "^1.95.0"
"@solana/web3.js": "^1.95.0",
"@stellar/stellar-sdk": "^13.1.0"
},
"peerDependenciesMeta": {
"@stellar/stellar-sdk": {
Expand All @@ -61,11 +99,18 @@
"devDependencies": {
"@commitlint/cli": "^19.6.0",
"@commitlint/config-conventional": "^19.6.0",
"@size-limit/preset-small-lib": "^11.0.0",
"@solana/web3.js": "^1.98.4",
"@stellar/stellar-sdk": "^13.1.0",
"@types/node": "^25.9.1",
"esbuild": "^0.21.0",
"husky": "^9.1.0",
"prettier": "^3.4.0",
"rollup": "^4.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"size-limit": "^11.0.0",
"tsup": "^8.4.0",
"tsx": "^4.0.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
}
Expand Down
Loading