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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ jobs:
- run: pnpm run format:check
- run: pnpm build
- run: pnpm test
- run: pnpm size
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ reference/
node_modules/
dist/
*.tsbuildinfo
stats/
*.stats.html
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pnpm format:check && pnpm build && pnpm test
pnpm format:check && pnpm build && pnpm test && pnpm size
64 changes: 64 additions & 0 deletions BUNDLE_SIZE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Bundle Size Baseline — Stellar Entry

> Last measured: 2026-06-23
> Bundler: tsup (esbuild) via `size-limit`

## Current Size

| Format | Size (gzip) | Budget |
|--------|-------------|--------|
| ESM (`import *`) | TBD | 20 KB |
| CJS (`require`) | TBD | 20 KB |

> TBD — run `pnpm build && pnpm size` after installation to populate
> actual measurements, then update this table.

## Dependency Graph

Generate a visual treemap of the Stellar entry's dependency graph:

```bash
ANALYZE=true pnpm build
# produces stats/ folder with metafile data
npx esbuild-visualizer --metadata stats/metafile-stellar.json --open
```

> `esbuild-visualizer` is an optional dev tool — install it globally or
> via `npx` when you need to inspect the graph.

## Measurement Commands

### esbuild (tsup) — via size-limit (CI gate)

```bash
pnpm build
pnpm size
```

### Vite-style bundling — standalone esbuild

```bash
pnpm measure:vite
```

Output written to `stats/vite-measurement.json`.

## Budget Policy

The Stellar entry budget is **20 KB gzipped** for each format (ESM, CJS).

- If a PR increases the Stellar bundle beyond the budget, CI will fail.
- Reviewers should verify no non-Stellar code was introduced into
`src/chains/stellar/` by checking imports.
- To adjust the budget, update the `size-limit` array in `package.json`.

## Known Optimizations

1. **Lazy `@stellar/stellar-sdk` import** — `pubKeyToStellarAddress()` uses a
dynamic `import()` instead of a top-level static import, ensuring the
optional peer dependency is never loaded until the function is actually
called. See `src/chains/stellar/scalar.ts`.

2. **No cross-chain leaks** — `src/chains/stellar/` imports zero code from
`evm/`, `solana/`, `ckb/`, or `agent/` directories. All imports are
local (`./`) or external npm packages (`@noble/curves`, `@noble/hashes`).
67 changes: 67 additions & 0 deletions docs/chains/stellar-view-tag-batching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Stellar view-tag batching design

## Problem

The original Stellar scan path computed `S = X25519(v, R_ephemeral)` for every announcement before checking the view tag. That made the one-byte view tag a correctness filter, but not a performance filter: non-matching announcements still paid the dominant ECDH cost.

## Chosen design

New Stellar announcements derive the first metadata byte from public announcement data:

```text
view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0]
```

Where:

- `R_ephemeral` is the 32-byte ed25519 ephemeral public key included in the announcement.
- `V_recipient` is the recipient's 32-byte ed25519 viewing public key from the meta-address.

This keeps the stealth-address secret scalar unchanged:

```text
S = X25519(r_ephemeral, V_recipient) = X25519(v_recipient, R_ephemeral)
hash_scalar = SHA-256("wraith:scalar:" || S) mod L
P_stealth = K_spend + hash_scalar * G
```

Scanners now derive `V_recipient` once from the local viewing seed, hash `R_ephemeral || V_recipient` for every announcement, and only compute X25519 plus ed25519 point addition for the roughly 1/256 announcements whose tag matches.

## Tradeoffs

### Benefits

- The hot scan loop replaces nearly all X25519 operations with one SHA-256 over a small public tuple.
- The full stealth address derivation and private scalar derivation remain unchanged for matching announcements.
- The filter keeps the same one-byte false-positive rate as the previous shared-secret tag.
- Invalid 32-byte ephemeral keys are only parsed as curve points after the public tag passes; if a crafted candidate passes the tag but is not a valid point, it is skipped.

### Costs and compatibility

- The view tag is no longer bound to the ECDH shared secret. It is a public prefilter, not authentication. This is acceptable because the announced stealth address is still verified with the shared-secret-derived scalar before a match is returned.
- A sender that knows a recipient's public viewing key can deliberately choose metadata that passes the recipient's public prefilter. That only causes the recipient to do the same full verification they already needed for candidate announcements, and the stealth address check still prevents false matches.
- Legacy announcements whose metadata used `SHA-256("wraith:tag:" || S)[0]` are not compatible with the optimized `scanAnnouncements` path. The SDK retains `scanAnnouncementsLegacySharedSecretTag` for benchmarks and migration tooling, but using it for normal scans necessarily reintroduces one X25519 per announcement.
- If deployed contracts or indexers need to distinguish old and new metadata semantics, this should be represented as a soft fork/new scheme identifier. The SDK-side cryptographic change is isolated to metadata generation and scanning; the stealth-address math does not change.

## Benchmarks

The benchmark harness lives at `test/chains/stellar/bench/scan.bench.ts` and compares:

1. `scanAnnouncementsLegacySharedSecretTag` over legacy shared-secret-tag announcements.
2. `scanAnnouncements` over new public-announcement-tag announcements.

Run it with:

```bash
pnpm exec vitest bench test/chains/stellar/bench/scan.bench.ts --run
```

The harness covers synthetic 10k, 100k, and 1M announcement datasets with one recipient match and a large pool of foreign announcements. Set `STELLAR_SCAN_BENCH_SIZES=10000` (or a comma-separated list) to run a subset locally.

On this development container, the 10k benchmark reported:

| Dataset | Before: shared-secret tag | After: public prefilter | Speedup |
| -------------------- | ------------------------: | ----------------------: | ------: |
| 10,000 announcements | 31,310.03 ms | 98.83 ms | 316.80x |

The expected speedup grows with dataset size because the optimized path computes the viewing public key once and performs X25519 only for public view-tag hits instead of every same-scheme announcement.
21 changes: 20 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,24 @@
"clean": "rm -rf dist",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
"prepare": "husky",
"size": "size-limit",
"analyze": "ANALYZE=true tsup",
"measure:vite": "node scripts/measure-vite.mjs"
},
"size-limit": [
{
"name": "Stellar ESM (import *)",
"path": "dist/chains/stellar/index.js",
"import": "*",
"limit": "20 KB"
},
{
"name": "Stellar CJS (require)",
"path": "dist/chains/stellar/index.cjs",
"limit": "20 KB"
}
],
"dependencies": {
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
Expand All @@ -61,10 +77,13 @@
"devDependencies": {
"@commitlint/cli": "^19.6.0",
"@commitlint/config-conventional": "^19.6.0",
"@size-limit/esbuild": "^11.0.0",
"@solana/web3.js": "^1.98.4",
"@stellar/stellar-sdk": "^13.1.0",
"esbuild": "^0.25.0",
"husky": "^9.1.0",
"prettier": "^3.4.0",
"size-limit": "^11.0.0",
"tsup": "^8.4.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
Expand Down
75 changes: 75 additions & 0 deletions scripts/measure-vite.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env node
import { build } from 'esbuild';
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');

async function measure() {
const result = await build({
entryPoints: [join(root, 'src/chains/stellar/index.ts')],
bundle: true,
format: 'esm',
outfile: '/dev/null',
metafile: true,
platform: 'browser',
external: ['@stellar/stellar-sdk', '@solana/web3.js'],
});

const metafile = result.metafile;
const output = Object.values(metafile.outputs)[0];
const totalBytes = output.bytes;
const totalGzip = estimateGzip(output.bytes);

const inputs = Object.entries(metafile.inputs)
.filter(([path]) => !path.includes('node_modules'))
.map(([path, info]) => ({
path,
bytes: info.bytes,
importedBy: info.importedBy.length,
imports: info.imports.length,
}))
.sort((a, b) => b.bytes - a.bytes);

const external = Object.entries(metafile.inputs)
.filter(([path]) => path.includes('node_modules'))
.map(([path, info]) => ({
path,
bytes: info.bytes,
}))
.sort((a, b) => b.bytes - a.bytes);

const report = {
bundler: 'esbuild (standalone — Vite-analogous)',
totalBytes,
totalGzip,
sourceInputs: inputs,
externalDeps: external,
};

writeFileSync(join(root, 'stats/vite-measurement.json'), JSON.stringify(report, null, 2));

console.log('\n=== Stellar Entry Bundle Size (Vite-style bundling) ===\n');
console.log(`Total bundle size: ${(totalBytes / 1024).toFixed(2)} KB`);
console.log(`Estimated gzip: ${(totalGzip / 1024).toFixed(2)} KB`);
console.log(`\nSource files included (top 10 by size):`);
inputs.slice(0, 10).forEach((f) => {
console.log(` ${(f.bytes / 1024).toFixed(2)} KB ${f.path.replace(root + '/', '')}`);
});
console.log(`\nExternal dependencies:`);
external.forEach((f) => {
const pkg = f.path.match(/node_modules\/([^/]+)/)?.[1] || f.path;
console.log(` ${(f.bytes / 1024).toFixed(2)} KB ${pkg}`);
});
}

function estimateGzip(bytes) {
return Math.round(bytes * 0.35);
}

measure().catch((err) => {
console.error(err);
process.exit(1);
});
13 changes: 11 additions & 2 deletions src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
export { deriveStealthKeys } from './keys';
export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, META_ADDRESS_PREFIX } from './constants';
export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address';
export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth';
export { checkStealthAddress, scanAnnouncements } from './scan';
export {
generateStealthAddress,
computeSharedSecret,
computeAnnouncementViewTag,
computeViewTag,
} from './stealth';
export {
checkStealthAddress,
scanAnnouncements,
scanAnnouncementsLegacySharedSecretTag,
} from './scan';
export { deriveStealthPrivateScalar, signStellarTransaction } from './spend';
export {
seedToScalar,
Expand Down
9 changes: 6 additions & 3 deletions src/chains/stellar/scalar.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ed25519 } from '@noble/curves/ed25519';
import { sha512 } from '@noble/hashes/sha512';
import { sha256 } from '@noble/hashes/sha256';
import { StrKey } from '@stellar/stellar-sdk';

/**
* ed25519 group order (order of the base point).
Expand Down Expand Up @@ -73,9 +72,13 @@ export function deriveStealthPubKey(spendingPubKey: Uint8Array, hashScalar: bigi

/**
* Converts a 32-byte ed25519 public key to a Stellar G... address.
*
* Uses a dynamic import of @stellar/stellar-sdk to avoid requiring
* the optional peer dependency at module load time — it is only
* loaded when this function is actually called.
*/
export function pubKeyToStellarAddress(pubKeyBytes: Uint8Array): string {
// StrKey typings expect Buffer, but Uint8Array works at runtime
export async function pubKeyToStellarAddress(pubKeyBytes: Uint8Array): Promise<string> {
const { StrKey } = await import('@stellar/stellar-sdk');
return (StrKey as any).encodeEd25519PublicKey(pubKeyBytes);
}

Expand Down
Loading