diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d24b1..7772db0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,4 @@ jobs: - run: pnpm run format:check - run: pnpm build - run: pnpm test + - run: pnpm size diff --git a/.gitignore b/.gitignore index e87a9c5..90ade0e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ reference/ node_modules/ dist/ *.tsbuildinfo +stats/ +*.stats.html diff --git a/.husky/pre-commit b/.husky/pre-commit index 1676926..e5bc311 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm format:check && pnpm build && pnpm test +pnpm format:check && pnpm build && pnpm test && pnpm size diff --git a/BUNDLE_SIZE.md b/BUNDLE_SIZE.md new file mode 100644 index 0000000..c08cfe8 --- /dev/null +++ b/BUNDLE_SIZE.md @@ -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`). diff --git a/docs/chains/stellar-view-tag-batching.md b/docs/chains/stellar-view-tag-batching.md new file mode 100644 index 0000000..e814b83 --- /dev/null +++ b/docs/chains/stellar-view-tag-batching.md @@ -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. diff --git a/package.json b/package.json index 5a0d713..6bd5fef 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/scripts/measure-vite.mjs b/scripts/measure-vite.mjs new file mode 100644 index 0000000..825c0f9 --- /dev/null +++ b/scripts/measure-vite.mjs @@ -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); +}); diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 44b7bd3..b478622 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -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, diff --git a/src/chains/stellar/scalar.ts b/src/chains/stellar/scalar.ts index a35857b..50c56cd 100644 --- a/src/chains/stellar/scalar.ts +++ b/src/chains/stellar/scalar.ts @@ -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). @@ -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 { + const { StrKey } = await import('@stellar/stellar-sdk'); return (StrKey as any).encodeEd25519PublicKey(pubKeyBytes); } diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index f5bf6a1..72888d2 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -1,4 +1,5 @@ -import { computeSharedSecret, computeViewTag } from './stealth'; +import { ed25519 } from '@noble/curves/ed25519'; +import { computeAnnouncementViewTag, computeSharedSecret, computeViewTag } from './stealth'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar'; import { SCHEME_ID } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; @@ -7,37 +8,80 @@ import { hexToBytes } from './utils'; /** * Checks whether a single announcement belongs to the recipient. * - * Uses only the viewing key and spending PUBLIC key (no spending private key): - * 1. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) - * 2. View tag quick filter (eliminates ~255/256 non-matches) - * 3. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L - * 4. Expected stealth pubkey = K_spend + hash_scalar * G - * 5. Compare with announced stealth address + * Uses the cheap public view-tag prefilter before the X25519 shared secret: + * 1. Derive the viewing public key once from the viewing seed + * 2. View tag quick filter from R_ephemeral || viewing_pubkey + * 3. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) only for tag hits + * 4. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L + * 5. Expected stealth pubkey = K_spend + hash_scalar * G + * 6. Compare with announced stealth address * * This is view-only: it can detect payments but NOT derive the spending key. */ -export function checkStealthAddress( +export async function checkStealthAddress( ephemeralPubKey: Uint8Array, viewingKey: Uint8Array, spendingPubKey: Uint8Array, viewTag: number, -): { +): Promise<{ isMatch: boolean; stealthAddress: string | null; hashScalar: bigint | null; stealthPubKeyBytes: Uint8Array | null; -} { - const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey); +}> { + const viewingPubKey = ed25519.getPublicKey(viewingKey); + return checkStealthAddressWithViewingPubKey( + ephemeralPubKey, + viewingKey, + viewingPubKey, + spendingPubKey, + viewTag, + ); +} - const computedTag = computeViewTag(sharedSecret); +async function checkStealthAddressWithViewingPubKey( + ephemeralPubKey: Uint8Array, + viewingKey: Uint8Array, + viewingPubKey: Uint8Array, + spendingPubKey: Uint8Array, + viewTag: number, +): Promise<{ + isMatch: boolean; + stealthAddress: string | null; + hashScalar: bigint | null; + stealthPubKeyBytes: Uint8Array | null; +}> { + const computedTag = computeAnnouncementViewTag(ephemeralPubKey, viewingPubKey); if (computedTag !== viewTag) { return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null }; } + try { + return await deriveStealthAddressFromAnnouncement( + ephemeralPubKey, + viewingKey, + spendingPubKey, + ); + } catch { + return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null }; + } +} + +async function deriveStealthAddressFromAnnouncement( + ephemeralPubKey: Uint8Array, + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, +): Promise<{ + isMatch: boolean; + stealthAddress: string | null; + hashScalar: bigint | null; + stealthPubKeyBytes: Uint8Array | null; +}> { + const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey); 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 }; } @@ -53,13 +97,14 @@ export function checkStealthAddress( * The stealth private scalar is: (spending_scalar + hash_scalar) mod L * This matches the EVM version: p_stealth = (m + s_h) mod n */ -export function scanAnnouncements( +export async function scanAnnouncements( announcements: Announcement[], viewingKey: Uint8Array, spendingPubKey: Uint8Array, spendingScalar: bigint, -): MatchedAnnouncement[] { +): Promise { const matched: MatchedAnnouncement[] = []; + const viewingPubKey = ed25519.getPublicKey(viewingKey); for (const ann of announcements) { if (ann.schemeId !== SCHEME_ID) continue; @@ -71,7 +116,13 @@ export function scanAnnouncements( const ephPubKey = hexToBytes(ann.ephemeralPubKey); if (ephPubKey.length !== 32) continue; - const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag); + const result = await checkStealthAddressWithViewingPubKey( + ephPubKey, + viewingKey, + viewingPubKey, + spendingPubKey, + viewTag, + ); if ( result.isMatch && @@ -91,3 +142,56 @@ export function scanAnnouncements( return matched; } + +/** + * Pre-optimization scanner retained for benchmarks and migration analysis. + * + * This matches the old Stellar path: every same-scheme announcement pays for + * X25519 first, computes the legacy shared-secret tag second, and only then + * compares the announced stealth address. + */ +export async function scanAnnouncementsLegacySharedSecretTag( + announcements: Announcement[], + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, + spendingScalar: bigint, +): Promise { + const matched: MatchedAnnouncement[] = []; + + for (const ann of announcements) { + if (ann.schemeId !== SCHEME_ID) continue; + + const metadataBytes = hexToBytes(ann.metadata); + if (metadataBytes.length === 0) continue; + const viewTag = metadataBytes[0]; + + const ephPubKey = hexToBytes(ann.ephemeralPubKey); + if (ephPubKey.length !== 32) continue; + + let sharedSecret: Uint8Array; + try { + sharedSecret = computeSharedSecret(viewingKey, ephPubKey); + } catch { + continue; + } + + const computedTag = computeViewTag(sharedSecret); + if (computedTag !== viewTag) continue; + + const hScalar = hashToScalar(sharedSecret); + const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar); + const stealthAddress = await pubKeyToStellarAddress(stealthPubKeyBytes); + + if (stealthAddress === ann.stealthAddress) { + const stealthPrivateScalar = (spendingScalar + hScalar) % L; + + matched.push({ + ...ann, + stealthPrivateScalar, + stealthPubKeyBytes, + }); + } + } + + return matched; +} diff --git a/src/chains/stellar/stealth.ts b/src/chains/stellar/stealth.ts index 526cf1d..04ba1eb 100644 --- a/src/chains/stellar/stealth.ts +++ b/src/chains/stellar/stealth.ts @@ -2,8 +2,11 @@ import { ed25519 } from '@noble/curves/ed25519'; import { x25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { edwardsToMontgomeryPub, edwardsToMontgomeryPriv } from '@noble/curves/ed25519'; -import type { GeneratedStealthAddress } from './types'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './scalar'; +import type { GeneratedStealthAddress } from './types'; + +const VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); +const LEGACY_VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:tag:'); /** * Generates a one-time stealth address for a recipient on Stellar. @@ -12,7 +15,7 @@ import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './sca * 1. Generate ephemeral ed25519 keypair (r, R) * 2. ECDH: shared_secret = X25519(r, V_recipient) * 3. hash_scalar = SHA-256("wraith:scalar:" || shared_secret) mod L - * 4. view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * 4. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R || V)[0] * 5. P_stealth = K_spend + hash_scalar * G (point addition) * 6. stealth_address = Stellar encoding of P_stealth * @@ -23,23 +26,23 @@ import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './sca * @param viewingPubKey Recipient's 32-byte ed25519 viewing public key. * @param ephemeralSeed Optional 32-byte seed for deterministic testing. */ -export function generateStealthAddress( +export async function generateStealthAddress( spendingPubKey: Uint8Array, viewingPubKey: Uint8Array, ephemeralSeed?: Uint8Array, -): GeneratedStealthAddress { +): Promise { const ephSeed = ephemeralSeed ?? ed25519.utils.randomPrivateKey(); const ephPubKey = ed25519.getPublicKey(ephSeed); const sharedSecret = computeSharedSecret(ephSeed, viewingPubKey); - const viewTag = computeViewTag(sharedSecret); + const viewTag = computeAnnouncementViewTag(ephPubKey, viewingPubKey); const hScalar = hashToScalar(sharedSecret); const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar); - const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes); + const stealthAddress = await pubKeyToStellarAddress(stealthPubKeyBytes); return { stealthAddress, @@ -59,13 +62,38 @@ export function computeSharedSecret(privateKey: Uint8Array, publicKey: Uint8Arra } /** - * Computes the view tag from a shared secret. - * view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * Computes the view tag from the public announcement tuple. + * + * view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] + * + * The tag intentionally depends only on public data already present in the + * announcement/meta-address. Scanners can reject ~255/256 announcements with + * one SHA-256 instead of paying for X25519 first; only candidates that pass + * this public prefilter need the full shared-secret derivation. + */ +export function computeAnnouncementViewTag( + ephemeralPubKey: Uint8Array, + viewingPubKey: Uint8Array, +): number { + const input = new Uint8Array( + VIEW_TAG_PREFIX.length + ephemeralPubKey.length + viewingPubKey.length, + ); + input.set(VIEW_TAG_PREFIX); + input.set(ephemeralPubKey, VIEW_TAG_PREFIX.length); + input.set(viewingPubKey, VIEW_TAG_PREFIX.length + ephemeralPubKey.length); + return sha256(input)[0]; +} + +/** + * Computes the legacy view tag from a shared secret. + * + * @deprecated Stellar scanning now uses computeAnnouncementViewTag() so the + * view-tag filter runs before X25519. This function is kept for compatibility + * checks and benchmark comparisons with the pre-batching scan path. */ export function computeViewTag(sharedSecret: Uint8Array): number { - const prefix = new TextEncoder().encode('wraith:tag:'); - const input = new Uint8Array(prefix.length + sharedSecret.length); - input.set(prefix); - input.set(sharedSecret, prefix.length); + const input = new Uint8Array(LEGACY_VIEW_TAG_PREFIX.length + sharedSecret.length); + input.set(LEGACY_VIEW_TAG_PREFIX); + input.set(sharedSecret, LEGACY_VIEW_TAG_PREFIX.length); return sha256(input)[0]; } diff --git a/test/chains/stellar/bench/scan.bench.ts b/test/chains/stellar/bench/scan.bench.ts new file mode 100644 index 0000000..177401b --- /dev/null +++ b/test/chains/stellar/bench/scan.bench.ts @@ -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 { + 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 +> { + 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, + ); + } +}); diff --git a/test/chains/stellar/e2e.test.ts b/test/chains/stellar/e2e.test.ts index fc90318..8a21ecc 100644 --- a/test/chains/stellar/e2e.test.ts +++ b/test/chains/stellar/e2e.test.ts @@ -16,7 +16,7 @@ const testSig = new Uint8Array(64).fill(0xaa); const fixedSeed = new Uint8Array(32).fill(0xcc); describe('e2e: full stealth payment flow on Stellar', () => { - test('derive → generate → scan → spend → verify', () => { + test('derive → generate → scan → spend → verify', async () => { const keys = deriveStealthKeys(testSig); const meta = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey); @@ -24,7 +24,7 @@ describe('e2e: full stealth payment flow on Stellar', () => { const decoded = decodeStealthMetaAddress(meta); - const stealth = generateStealthAddress( + const stealth = await generateStealthAddress( decoded.spendingPubKey, decoded.viewingPubKey, fixedSeed, @@ -39,7 +39,7 @@ describe('e2e: full stealth payment flow on Stellar', () => { metadata: stealth.viewTag.toString(16).padStart(2, '0'), }; - const matched = scanAnnouncements( + const matched = await scanAnnouncements( [announcement], keys.viewingKey, keys.spendingPubKey, diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index 4cbbc5b..1531c93 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -1,7 +1,16 @@ import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; -import { generateStealthAddress } from '../../../src/chains/stellar/stealth'; -import { checkStealthAddress, scanAnnouncements } from '../../../src/chains/stellar/scan'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../src/chains/stellar/stealth'; +import { + checkStealthAddress, + 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 } from '../../../src/chains/stellar/types'; @@ -9,11 +18,14 @@ import type { Announcement } from '../../../src/chains/stellar/types'; const testSig = new Uint8Array(64).fill(0xaa); describe('checkStealthAddress', () => { - test('matches own announcement', () => { + test('matches own announcement', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ); - const result = checkStealthAddress( + const result = await checkStealthAddress( stealth.ephemeralPubKey, keys.viewingKey, keys.spendingPubKey, @@ -24,12 +36,15 @@ describe('checkStealthAddress', () => { expect(result.stealthAddress).toBe(stealth.stealthAddress); }); - test('rejects wrong view tag', () => { + test('rejects wrong view tag', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ); const wrongTag = (stealth.viewTag + 1) % 256; - const result = checkStealthAddress( + const result = await checkStealthAddress( stealth.ephemeralPubKey, keys.viewingKey, keys.spendingPubKey, @@ -40,14 +55,17 @@ describe('checkStealthAddress', () => { expect(result.stealthAddress).toBeNull(); }); - test('rejects wrong viewing key', () => { + test('rejects wrong viewing key', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ); const otherSig = new Uint8Array(64).fill(0xbb); const otherKeys = deriveStealthKeys(otherSig); - const result = checkStealthAddress( + const result = await checkStealthAddress( stealth.ephemeralPubKey, otherKeys.viewingKey, keys.spendingPubKey, @@ -61,9 +79,12 @@ describe('checkStealthAddress', () => { }); describe('scanAnnouncements', () => { - test('finds matching payments', () => { + test('finds matching payments', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ); const announcements: Announcement[] = [ { @@ -75,7 +96,7 @@ describe('scanAnnouncements', () => { }, ]; - const matched = scanAnnouncements( + const matched = await scanAnnouncements( announcements, keys.viewingKey, keys.spendingPubKey, @@ -87,9 +108,12 @@ describe('scanAnnouncements', () => { expect(matched[0].stealthPubKeyBytes).toBeInstanceOf(Uint8Array); }); - test('skips wrong scheme ID', () => { + test('skips wrong scheme ID', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ); const announcements: Announcement[] = [ { @@ -101,7 +125,7 @@ describe('scanAnnouncements', () => { }, ]; - const matched = scanAnnouncements( + const matched = await scanAnnouncements( announcements, keys.viewingKey, keys.spendingPubKey, @@ -110,13 +134,103 @@ describe('scanAnnouncements', () => { expect(matched).toHaveLength(0); }); - test('filters mix of own and foreign announcements', () => { + test('skips invalid ephemeral keys even when the public view tag matches', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const invalidEphemeralPubKey = new Uint8Array(32); + const matchingPublicTag = computeAnnouncementViewTag( + invalidEphemeralPubKey, + keys.viewingPubKey, + ); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(invalidEphemeralPubKey), + metadata: matchingPublicTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = await scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(0); + }); + + test('keeps legacy shared-secret view tags on the legacy scanner path', async () => { + const keys = deriveStealthKeys(testSig); + let ephemeralSeed = new Uint8Array(32).fill(0x11); + let stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ephemeralSeed, + ); + let sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + let legacyTag = computeViewTag(sharedSecret); + + // Use a deterministic seed whose legacy shared-secret tag differs from the + // optimized public-announcement tag so the migration boundary is explicit. + for (let i = 0; legacyTag === stealth.viewTag && i < 255; i++) { + ephemeralSeed = new Uint8Array(32).fill(0x12 + i); + stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ephemeralSeed, + ); + sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + legacyTag = computeViewTag(sharedSecret); + } + + expect(legacyTag).not.toBe(stealth.viewTag); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: legacyTag.toString(16).padStart(2, '0'), + }, + ]; + + expect( + await scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ), + ).toHaveLength(0); + + const legacyMatched = await scanAnnouncementsLegacySharedSecretTag( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(legacyMatched).toHaveLength(1); + expect(legacyMatched[0].stealthAddress).toBe(stealth.stealthAddress); + }); + + test('filters mix of own and foreign announcements', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ); const otherSig = new Uint8Array(64).fill(0xbb); const otherKeys = deriveStealthKeys(otherSig); - const otherStealth = generateStealthAddress(otherKeys.spendingPubKey, otherKeys.viewingPubKey); + const otherStealth = await generateStealthAddress( + otherKeys.spendingPubKey, + otherKeys.viewingPubKey, + ); const announcements: Announcement[] = [ { @@ -135,7 +249,7 @@ describe('scanAnnouncements', () => { }, ]; - const matched = scanAnnouncements( + const matched = await scanAnnouncements( announcements, keys.viewingKey, keys.spendingPubKey, diff --git a/test/chains/stellar/spend.test.ts b/test/chains/stellar/spend.test.ts index f480945..237f473 100644 --- a/test/chains/stellar/spend.test.ts +++ b/test/chains/stellar/spend.test.ts @@ -9,9 +9,13 @@ const testSig = new Uint8Array(64).fill(0xaa); const fixedSeed = new Uint8Array(32).fill(0xcc); describe('deriveStealthPrivateScalar', () => { - test('returns a valid bigint scalar', () => { + test('returns a valid bigint scalar', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); const scalar = deriveStealthPrivateScalar( keys.spendingScalar, @@ -23,9 +27,13 @@ describe('deriveStealthPrivateScalar', () => { expect(scalar > 0n).toBe(true); }); - test('derived scalar produces the stealth public key', () => { + test('derived scalar produces the stealth public key', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); const scalar = deriveStealthPrivateScalar( keys.spendingScalar, @@ -34,14 +42,18 @@ describe('deriveStealthPrivateScalar', () => { ); const derivedPub = ed25519.ExtendedPoint.BASE.multiply(scalar).toRawBytes(); - const derivedAddress = pubKeyToStellarAddress(derivedPub); + const derivedAddress = await pubKeyToStellarAddress(derivedPub); expect(derivedAddress).toBe(stealth.stealthAddress); }); - test('deterministic', () => { + test('deterministic', async () => { const keys = deriveStealthKeys(testSig); - const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); + const stealth = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); const s1 = deriveStealthPrivateScalar( keys.spendingScalar, diff --git a/test/chains/stellar/stealth.test.ts b/test/chains/stellar/stealth.test.ts index c4a053d..408a44a 100644 --- a/test/chains/stellar/stealth.test.ts +++ b/test/chains/stellar/stealth.test.ts @@ -6,9 +6,13 @@ const testSig = new Uint8Array(64).fill(0xaa); const fixedSeed = new Uint8Array(32).fill(0xcc); describe('generateStealthAddress', () => { - test('generates valid stealth address', () => { + test('generates valid stealth address', async () => { const keys = deriveStealthKeys(testSig); - const result = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); + const result = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); expect(result.stealthAddress).toMatch(/^G[A-Z2-7]{55}$/); expect(result.ephemeralPubKey).toBeInstanceOf(Uint8Array); @@ -17,32 +21,56 @@ describe('generateStealthAddress', () => { expect(result.viewTag).toBeLessThanOrEqual(255); }); - test('deterministic with fixed ephemeral seed', () => { + test('deterministic with fixed ephemeral seed', async () => { const keys = deriveStealthKeys(testSig); - const r1 = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); - const r2 = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); + const r1 = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); + const r2 = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); expect(r1.stealthAddress).toBe(r2.stealthAddress); expect(r1.ephemeralPubKey).toEqual(r2.ephemeralPubKey); expect(r1.viewTag).toBe(r2.viewTag); }); - test('different ephemeral seeds produce different addresses', () => { + test('different ephemeral seeds produce different addresses', async () => { const keys = deriveStealthKeys(testSig); - const r1 = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, fixedSeed); + const r1 = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + fixedSeed, + ); const altSeed = new Uint8Array(32).fill(0xdd); - const r2 = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, altSeed); + const r2 = await generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + altSeed, + ); expect(r1.stealthAddress).not.toBe(r2.stealthAddress); }); - test('different recipients produce different addresses', () => { + test('different recipients produce different addresses', async () => { const keys1 = deriveStealthKeys(testSig); const sig2 = new Uint8Array(64).fill(0xbb); const keys2 = deriveStealthKeys(sig2); - const r1 = generateStealthAddress(keys1.spendingPubKey, keys1.viewingPubKey, fixedSeed); - const r2 = generateStealthAddress(keys2.spendingPubKey, keys2.viewingPubKey, fixedSeed); + const r1 = await generateStealthAddress( + keys1.spendingPubKey, + keys1.viewingPubKey, + fixedSeed, + ); + const r2 = await generateStealthAddress( + keys2.spendingPubKey, + keys2.viewingPubKey, + fixedSeed, + ); expect(r1.stealthAddress).not.toBe(r2.stealthAddress); }); diff --git a/tsup.config.ts b/tsup.config.ts index 49648d3..b574ea8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -13,4 +13,5 @@ export default defineConfig({ splitting: true, clean: true, treeshake: true, + metafile: !!process.env.ANALYZE, });