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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to the Wraith Protocol SDK will be documented in this file.

## Upcoming: 2.0.0

### Performance

- **Stellar Streaming Scan Pipelining** (issue #126): `scanAnnouncementsStream` now pulls its `source` through a bounded pipeline (`src/chains/stellar/scanner/pipeline.ts`) instead of prefetching a strict window before scanning it, so RPC fetches for later pages overlap with CPU work scanning earlier ones. Peak memory stays O(window). `fetchAnnouncementsStream` and `scanAnnouncementsStream`'s public shapes are unchanged; the old windowed algorithm is retained as `scanAnnouncementsStreamSequential` for benchmark comparisons. See [`docs/chains/stellar-streaming-scan-pipeline.md`](./docs/chains/stellar-streaming-scan-pipeline.md) — measured 36% wall-clock reduction on the 10k-announcement canned benchmark.

### Changed

- **Stellar Chain Module Cryptographic Audit Fixes**: Applied all findings from independent cryptographic audit (issue #55). Breaking changes:
Expand Down
82 changes: 82 additions & 0 deletions docs/chains/stellar-streaming-scan-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Stellar streaming scan: overlapping RPC with CPU work

## Problem

`scanAnnouncementsStream` pulled announcements from its `source` in strict windows:
await up to `window` items from the source, then scan all of them, then repeat. While a
window was being scanned, the source (typically {@link fetchAnnouncementsStream} paging
through Soroban RPC) sat idle — no fetch for the next page could start until the current
window finished. On a cold scan, that serializes RPC latency and CPU decrypt cost that
could otherwise overlap.

## Chosen design

`src/chains/stellar/scanner/pipeline.ts` adds a generic `pipeline(source, capacity)`
helper: a bounded producer/consumer queue. A background pump keeps pulling from `source`
up to `capacity` items ahead of what the consumer has read. Because starting the next pull
from `source` kicks off its I/O immediately, that I/O runs concurrently with whatever
synchronous work the consumer is doing on already-buffered items — Node's event loop keeps
an in-flight `fetch()` progressing in the background while the main thread executes CPU
work on the previous batch.

`scanAnnouncementsStream` now wraps its `source` in `pipeline(source, window)` and scans
each item as it's pulled from the pipeline, instead of prefetching a window strictly before
scanning any of it:

```ts
const piped = pipeline(source, windowSize);
for await (const ann of piped) {
// scan ann immediately; the pipeline is already fetching ahead in the background
}
```

The pump pauses once `capacity` items are buffered, so an adversarially fast source paired
with a slow scan still can't grow memory past O(window) — the same bound the old
windowed implementation had.

`fetchAnnouncementsStream`'s public shape is unchanged: it's still a plain async generator
that yields one announcement at a time. `scanAnnouncementsStream`'s signature is also
unchanged. The old windowed algorithm is kept as `scanAnnouncementsStreamSequential`,
exported alongside `scanAnnouncementsStream` for benchmark comparisons, matching how
`scanAnnouncementsLegacySharedSecretTag` is retained for the view-tag-batching benchmark.

## Cancellation and errors

Breaking out of the consumer's `for-await` loop calls `.return()` on the pipeline, which
calls `.return()` on `source` in its `finally` block — the same cancellation contract the
old implementation had, verified by the existing `scanAnnouncementsStream` cancellation
test. Errors thrown by `source` propagate to the consumer once any already-buffered items
are drained.

## Benchmarks

The benchmark harness lives at `test/chains/stellar/bench/scan.bench.ts`, in the "Stellar
streaming scan pipelining" section. It mimics `fetchAnnouncementsStream`'s paging with a
mock source that pays a fixed page latency (15ms) per 1,000-announcement page, then
compares:

1. `scanAnnouncementsStreamSequential` — window-then-scan, no overlap.
2. `scanAnnouncementsStream` — pipelined fetch+scan.

Run it with:

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

On this development container, the 10k-announcement canned dataset reported:

| Dataset | Before: sequential window | After: pipelined | Speedup |
| -------------------- | ------------------------: | ---------------: | ------: |
| 10,000 announcements | 537.14 ms | 343.50 ms | 1.56x |

That's a 36% reduction in wall-clock time, clearing the 30% target. The gain scales with
how many pages a cold scan spans and how close `window` is to the source's page size —
a `window` much smaller than the page size limits how far the pump can prefetch ahead of
the scan.

`test/chains/stellar/scanner/pipeline.test.ts` additionally asserts the underlying overlap
mechanism directly (a mock producer/consumer pair with matched I/O and CPU delays), and
asserts the bounded-queue backpressure property with a fast producer paired with an
artificially slow consumer, so both acceptance criteria run under `pnpm test`, not just the
excluded `bench/` folder.
94 changes: 85 additions & 9 deletions src/chains/stellar/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './
import { SCHEME_ID, SCHEME_ID_V2 } from './constants';
import type { Announcement, MatchedAnnouncement } from './types';
import { hexToBytes } from './utils';
import { pipeline } from './scanner/pipeline';

/**
* Streaming announcement scanner. Pulls announcements from `source` in windows of
* `opts.window` (default 64), scans each window, and yields matches immediately.
* Streaming announcement scanner. Pipelines `source` through a bounded queue
* of size `opts.window` (default 64) so fetching stays ahead of decryption,
* and yields matches as soon as they're found.
*
* Uses the cheap public view-tag prefilter before the X25519 shared secret:
* 1. Derive the viewing public key once from the viewing seed
Expand All @@ -16,13 +18,22 @@ import { hexToBytes } from './utils';
* 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
* Peak memory is O(window) — never accumulates the full announcement set.
*
* Unlike fetching all announcements up front and then scanning them, `source` is
* pulled continuously in the background (see {@link pipeline}) while each buffered
* announcement is scanned, so RPC round-trips for later pages overlap with the CPU
* cost of scanning earlier ones instead of running strictly one after the other.
* Peak memory is still O(window) — the queue never buffers more than `window`
* announcements ahead of the scan, so a fast source paired with a slow scan
* doesn't grow memory unbounded.
*
* Cancellation is clean: breaking out of the `for-await` loop triggers the `finally`
* block which calls `.return()` on the source iterator, stopping upstream I/O.
* block which stops the pipeline, which in turn calls `.return()` on the source
* iterator, stopping upstream I/O.
*
* @param source Async iterable of announcements (e.g. from {@link fetchAnnouncementsStream}).
* @param opts.window Max announcements buffered at once. Smaller = less memory, larger = fewer
* async round-trips to the source. Default: 64.
* @param opts.window Max announcements buffered ahead of the scan. Smaller = less memory,
* larger = more overlap between fetching and scanning. Default: 64.
*/
export async function* scanAnnouncementsStream(
source: AsyncIterable<Announcement>,
Expand All @@ -32,11 +43,71 @@ export async function* scanAnnouncementsStream(
opts: { window?: number } = {},
): AsyncGenerator<MatchedAnnouncement> {
const windowSize = Math.max(1, opts.window ?? 64);
const viewingPubKey = ed25519.getPublicKey(viewingKey);
const piped = pipeline(source, windowSize);

try {
for await (const ann of piped) {
if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) 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;

const result = checkStealthAddressWithViewingPubKey(
ephPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
);

if (
result.isMatch &&
result.stealthAddress === ann.stealthAddress &&
result.hashScalar !== null &&
result.stealthPubKeyBytes !== null
) {
const stealthPrivateScalar = ((spendingScalar % L) + result.hashScalar) % L;
yield {
...ann,
stealthPrivateScalar,
stealthPubKeyBytes: result.stealthPubKeyBytes,
};
}
}
} finally {
// Signal the pipeline (and transitively the source) to stop when consumer cancels early
await piped.return(undefined);
}
}

/**
* Pre-pipelining scanner retained for benchmarks.
*
* Matches the old streaming scan path: it prefetches up to `window` announcements
* strictly before scanning any of them, so no RPC fetch for the next window can
* start until the current window is fully scanned. {@link scanAnnouncementsStream}
* replaced this with a pipelined version that overlaps fetching with scanning.
*
* @see {@link scanAnnouncementsStream}
*/
export async function* scanAnnouncementsStreamSequential(
source: AsyncIterable<Announcement>,
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
spendingScalar: bigint,
opts: { window?: number } = {},
): AsyncGenerator<MatchedAnnouncement> {
const windowSize = Math.max(1, opts.window ?? 64);
const viewingPubKey = ed25519.getPublicKey(viewingKey);
const iter = source[Symbol.asyncIterator]();

try {
while (true) {
// Prefetch up to windowSize announcements — bounds peak memory to O(window)
const batch: Announcement[] = [];
for (let i = 0; i < windowSize; i++) {
const next = await iter.next();
Expand All @@ -56,7 +127,13 @@ export async function* scanAnnouncementsStream(
const ephPubKey = hexToBytes(ann.ephemeralPubKey);
if (ephPubKey.length !== 32) continue;

const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag);
const result = checkStealthAddressWithViewingPubKey(
ephPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
);

if (
result.isMatch &&
Expand All @@ -76,7 +153,6 @@ export async function* scanAnnouncementsStream(
if (batch.length < windowSize) break;
}
} finally {
// Signal upstream to stop I/O when consumer cancels early
await iter.return?.();
}
}
Expand Down
1 change: 1 addition & 0 deletions src/chains/stellar/scanner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { pipeline } from './pipeline';
93 changes: 93 additions & 0 deletions src/chains/stellar/scanner/pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/** Resolvable/rejectable promise used to gate the producer and consumer loops. */
class Deferred<T = void> {
readonly promise: Promise<T>;
resolve!: (value: T) => void;

constructor() {
this.promise = new Promise<T>((resolve) => {
this.resolve = resolve;
});
}
}

/**
* Pipelines an async iterable through a bounded in-memory queue so a slow
* consumer (e.g. CPU-bound decryption) overlaps with a producer that is
* mostly waiting on I/O (e.g. RPC pagination), instead of alternating
* "await a full page, then process it" in lockstep.
*
* A background pump continuously pulls from `source` and buffers up to
* `capacity` items ahead of what the consumer has read. Because pulling the
* next item from `source` starts its I/O immediately, that I/O runs
* concurrently with whatever synchronous work the consumer is doing on
* already-buffered items — Node's event loop keeps the in-flight network
* call progressing in the background while the main thread executes the
* consumer's CPU-bound step.
*
* The pump pauses once the queue is full, so an adversarially fast producer
* paired with a slow consumer cannot grow memory past O(capacity) items.
*
* Breaking out of the consumer's `for-await` loop (or calling `.return()`)
* propagates to `source` via its `.return()`, matching plain async-generator
* cancellation semantics.
*
* @param source Async iterable to pull from (e.g. {@link fetchAnnouncementsStream}).
* @param capacity Max items buffered ahead of the consumer. Must be >= 1.
*/
export async function* pipeline<T>(source: AsyncIterable<T>, capacity: number): AsyncGenerator<T> {
const cap = Math.max(1, capacity);
const buffer: T[] = [];
let producerDone = false;
let producerErrored = false;
let producerError: unknown;

let itemAvailable = new Deferred();
let spaceAvailable = new Deferred();
spaceAvailable.resolve();

const iter = source[Symbol.asyncIterator]();

const pump = (async () => {
try {
while (true) {
if (buffer.length >= cap) {
await spaceAvailable.promise;
spaceAvailable = new Deferred();
}

const next = await iter.next();
if (next.done) break;

buffer.push(next.value);
itemAvailable.resolve();
}
} catch (err) {
producerErrored = true;
producerError = err;
} finally {
producerDone = true;
itemAvailable.resolve();
}
})();

try {
while (true) {
if (buffer.length === 0) {
if (producerDone) {
if (producerErrored) throw producerError;
break;
}
await itemAvailable.promise;
itemAvailable = new Deferred();
continue;
}

const value = buffer.shift() as T;
spaceAvailable.resolve();
yield value;
}
} finally {
await iter.return?.();
await pump.catch(() => {});
}
}
Loading
Loading