Skip to content
Merged
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
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`).
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@
"prepare": "husky",
"test:fuzz": "FC_RUNS=100000 vitest run test/chains/stellar/properties.test.ts"
},
"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 @@ -77,6 +90,7 @@
"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",
"fake-indexeddb": "^6.2.5",
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);
});
10 changes: 5 additions & 5 deletions src/chains/stellar/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,12 @@ export async function* scanAnnouncementsStream(
*
* @see {@link scanAnnouncements}
*/
export function checkStealthAddress(
export async function checkStealthAddress(
ephemeralPubKey: Uint8Array,
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
viewTag: number,
): {
): Promise<{
isMatch: boolean;
stealthAddress: string | null;
hashScalar: bigint | null;
Expand Down Expand Up @@ -168,7 +168,7 @@ function deriveStealthAddressFromAnnouncement(
const hScalar = hashToScalar(sharedSecret);

const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar);
const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes);
const stealthAddress = await pubKeyToStellarAddress(stealthPubKeyBytes);

return { isMatch: true, stealthAddress, hashScalar: hScalar, stealthPubKeyBytes };
}
Expand Down Expand Up @@ -206,12 +206,12 @@ function deriveStealthAddressFromAnnouncement(
*
* @see {@link deriveStealthPrivateScalar}
*/
export function scanAnnouncements(
export async function scanAnnouncements(
announcements: Announcement[],
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
spendingScalar: bigint,
): MatchedAnnouncement[] {
): Promise<MatchedAnnouncement[]> {
const matched: MatchedAnnouncement[] = [];
const viewingPubKey = ed25519.getPublicKey(viewingKey);

Expand Down
6 changes: 3 additions & 3 deletions src/chains/stellar/stealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ const LEGACY_VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:tag:');
*
* @see {@link scanAnnouncements} to detect announcements for generated addresses.
*/
export function generateStealthAddress(
export async function generateStealthAddress(
spendingPubKey: Uint8Array,
viewingPubKey: Uint8Array,
ephemeralSeed?: Uint8Array,
): GeneratedStealthAddress {
): Promise<GeneratedStealthAddress> {
const ephSeed = ephemeralSeed ?? ed25519.utils.randomPrivateKey();
const ephPubKey = ed25519.getPublicKey(ephSeed);

Expand All @@ -57,7 +57,7 @@ export function generateStealthAddress(

const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar);

const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes);
const stealthAddress = await pubKeyToStellarAddress(stealthPubKeyBytes);

return {
stealthAddress,
Expand Down
162 changes: 162 additions & 0 deletions test/chains/stellar/bench/scan.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { bench, describe, expect, test } from 'vitest';
import { deriveStealthKeys } from '../../../../src/chains/stellar/keys';
import {
computeAnnouncementViewTag,
computeSharedSecret,
computeViewTag,
generateStealthAddress,
} from '../../../../src/chains/stellar/stealth';
import {
scanAnnouncements,
scanAnnouncementsLegacySharedSecretTag,
} from '../../../../src/chains/stellar/scan';
import { SCHEME_ID } from '../../../../src/chains/stellar/constants';
import { bytesToHex } from '../../../../src/chains/stellar/utils';
import type { Announcement, StealthKeys } from '../../../../src/chains/stellar/types';

const MATCH_INDEX = 997;
const POOL_SIZE = 512;
const DEFAULT_DATASET_SIZES = [10_000, 100_000, 1_000_000] as const;
const DATASET_SIZES = (
process.env.STELLAR_SCAN_BENCH_SIZES?.split(',').map(Number) ?? [...DEFAULT_DATASET_SIZES]
).filter((size) => Number.isFinite(size) && size > 0);
const BENCH_OPTIONS = { time: 1, iterations: 1, warmupTime: 0, warmupIterations: 0 };

const keys = deriveStealthKeys(new Uint8Array(64).fill(0xaa));
const foreignKeys = deriveStealthKeys(new Uint8Array(64).fill(0xbb));

function seedFor(index: number): Uint8Array {
const seed = new Uint8Array(32);
let state = (index + 1) * 0x9e3779b1;
for (let i = 0; i < seed.length; i++) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
seed[i] = state & 0xff;
}
return seed;
}

async function makeAnnouncementFor(
recipient: StealthKeys,
ephemeralSeed: Uint8Array,
tagScheme: 'legacy-shared-secret' | 'public-announcement',
): Promise<Announcement> {
const stealth = await generateStealthAddress(
recipient.spendingPubKey,
recipient.viewingPubKey,
ephemeralSeed,
);
const sharedSecret = computeSharedSecret(ephemeralSeed, recipient.viewingPubKey);
const viewTag =
tagScheme === 'legacy-shared-secret'
? computeViewTag(sharedSecret)
: computeAnnouncementViewTag(stealth.ephemeralPubKey, recipient.viewingPubKey);

return {
schemeId: SCHEME_ID,
stealthAddress: stealth.stealthAddress,
caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey),
metadata: viewTag.toString(16).padStart(2, '0'),
};
}

let pools: { legacy: Announcement[]; optimized: Announcement[] } | undefined;
let matchingAnnouncements: { legacy: Announcement; optimized: Announcement } | undefined;

async function initFixtures() {
if (pools && matchingAnnouncements) return;
pools = {
legacy: await Promise.all(
Array.from({ length: POOL_SIZE }, (_, i) =>
makeAnnouncementFor(foreignKeys, seedFor(i), 'legacy-shared-secret'),
),
),
optimized: await Promise.all(
Array.from({ length: POOL_SIZE }, (_, i) =>
makeAnnouncementFor(foreignKeys, seedFor(i), 'public-announcement'),
),
),
};
matchingAnnouncements = {
legacy: await makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'legacy-shared-secret'),
optimized: await makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'public-announcement'),
};
}

function makeDataset(size: number, tagScheme: 'legacy' | 'optimized') {
const foreignPool = pools![tagScheme];
const matchingAnnouncement = matchingAnnouncements![tagScheme];

return Array.from({ length: size }, (_, i) =>
i === MATCH_INDEX ? matchingAnnouncement : foreignPool[i % foreignPool.length],
);
}

async function getDatasets(): Promise<
Map<number, { legacy: Announcement[]; optimized: Announcement[] }>
> {
await initFixtures();
return new Map(
DATASET_SIZES.map((size) => [
size,
{
legacy: makeDataset(size, 'legacy'),
optimized: makeDataset(size, 'optimized'),
},
]),
);
}

describe('Stellar scan benchmark fixtures', () => {
test('optimized scanner preserves correctness on the 10k synthetic dataset', async () => {
const datasets = await getDatasets();
const dataset = datasets.get(10_000)?.optimized;
expect(dataset).toBeDefined();

const matched = await scanAnnouncements(
dataset!,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingScalar,
);

expect(matched).toHaveLength(1);
expect(matched[0].stealthAddress).toBe(matchingAnnouncements!.optimized.stealthAddress);
});
});

describe('Stellar scan announcement view-tag batching', () => {
for (const size of DATASET_SIZES) {
bench(
`before: shared-secret view tag (${size.toLocaleString()} announcements)`,
async () => {
const datasets = await getDatasets();
const dataset = datasets.get(size)!;
await scanAnnouncementsLegacySharedSecretTag(
dataset.legacy,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingScalar,
);
},
BENCH_OPTIONS,
);

bench(
`after: public view-tag prefilter (${size.toLocaleString()} announcements)`,
async () => {
const datasets = await getDatasets();
const dataset = datasets.get(size)!;
await scanAnnouncements(
dataset.optimized,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingScalar,
);
},
BENCH_OPTIONS,
);
}
});
Loading