diff --git a/src/chains/stellar/announcements.ts b/src/chains/stellar/announcements.ts index bf03ddf..5f03b59 100644 --- a/src/chains/stellar/announcements.ts +++ b/src/chains/stellar/announcements.ts @@ -68,6 +68,8 @@ export class RetentionExceededError extends Error { * ``` * * @see {@link getDeployment} + * + * @deprecated Prefer {@link fetchAnnouncementsStream} for memory-efficient streaming. */ export async function fetchAnnouncements( chain?: string, @@ -177,6 +179,86 @@ export async function fetchAnnouncements( return returnsCursor ? { announcements: all, nextCursor } : all; } +/** + * Streaming version of announcement fetching. Yields announcements page by page + * from the Soroban RPC as they arrive, never holding more than one page in memory. + * + * Cancellation is automatic: breaking out of the `for-await` loop stops the stream. + * + * @param chain The chain identifier (default: "stellar"). + * @param sorobanUrl Optional override for the Soroban RPC URL. + */ +export async function* fetchAnnouncementsStream( + chain: string = 'stellar', + sorobanUrl?: string, +): AsyncGenerator { + const deployment = getDeployment(chain); + const url = sorobanUrl || deployment.sorobanUrl; + const announcerContract = deployment.contracts.announcer; + + const probeRes = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'getEvents', + params: { + startLedger: 1, + filters: [{ type: 'contract', contractIds: [announcerContract] }], + pagination: { limit: 1 }, + }, + }), + }); + + const probeData = await probeRes.json(); + let startLedger = 1; + + if (probeData.error?.message) { + const range = parseLedgerRange(probeData.error.message); + if (range) { + startLedger = Math.max(range.oldest, range.latest - 5000); + } else { + return; + } + } + + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const params: Record = { + filters: [{ type: 'contract', contractIds: [announcerContract] }], + pagination: cursor ? { limit: 1000, cursor } : { limit: 1000 }, + }; + + if (!cursor) { + params.startLedger = startLedger; + } + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'getEvents', params }), + }); + + const data = await res.json(); + const events: Record[] = data.result?.events ?? []; + + for (const event of events) { + const ann = parseAnnouncementEvent(event); + if (ann) yield ann; + } + + if (events.length < 1000) { + hasMore = false; + } else { + cursor = data.result?.cursor; + if (!cursor) hasMore = false; + } + } +} + async function getSorobanLedgerWindow( sorobanUrl: string, announcerContract: string, diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 7ca048f..f8a02db 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -2,7 +2,7 @@ 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 { checkStealthAddress, scanAnnouncements, scanAnnouncementsStream } from './scan'; export { deriveStealthPrivateScalar, signStellarTransaction } from './spend'; export { seedToScalar, @@ -13,7 +13,7 @@ export { L, } from './scalar'; export { bytesToHex, hexToBytes } from './utils'; -export { fetchAnnouncements, RetentionExceededError } from './announcements'; +export { fetchAnnouncements, fetchAnnouncementsStream, RetentionExceededError } from './announcements'; export type { FetchAnnouncementsOptions, FetchAnnouncementsResult } from './announcements'; export { DEPLOYMENTS, getDeployment } from './deployments'; export type { StellarChainDeployment } from './deployments'; diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index d70f440..5f41810 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -4,6 +4,75 @@ import { SCHEME_ID } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; import { hexToBytes } from './utils'; +/** + * Streaming announcement scanner. Pulls announcements from `source` in windows of + * `opts.window` (default 64), scans each window, and yields matches immediately. + * + * Peak memory is O(window) — never accumulates the full announcement set. + * 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. + * + * @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. + */ +export async function* scanAnnouncementsStream( + source: AsyncIterable, + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, + spendingScalar: bigint, + opts: { window?: number } = {}, +): AsyncGenerator { + const windowSize = Math.max(1, opts.window ?? 64); + 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(); + if (next.done) break; + batch.push(next.value); + } + + if (batch.length === 0) break; + + for (const ann of batch) { + 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; + + const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag); + + if ( + result.isMatch && + result.stealthAddress === ann.stealthAddress && + result.hashScalar !== null && + result.stealthPubKeyBytes !== null + ) { + const stealthPrivateScalar = (spendingScalar + result.hashScalar) % L; + yield { + ...ann, + stealthPrivateScalar, + stealthPubKeyBytes: result.stealthPubKeyBytes, + }; + } + } + + if (batch.length < windowSize) break; + } + } finally { + // Signal upstream to stop I/O when consumer cancels early + await iter.return?.(); + } +} + /** * Checks whether one Stellar announcement can belong to a recipient. * @@ -61,6 +130,10 @@ export function checkStealthAddress( /** * Scans Stellar stealth announcements and returns the ones a recipient can spend. * + * @deprecated Prefer {@link scanAnnouncementsStream} for memory-efficient streaming. + * For large announcement sets this loads the full array into memory, which can + * exhaust TEE memory budgets. This function is kept for backward compatibility. + * * Use this after fetching Soroban announcements. The spending scalar is required * because matched results include the derived stealth private scalar for later * transaction signing. diff --git a/test/chains/stellar/announcements.test.ts b/test/chains/stellar/announcements.test.ts index 3ffcb92..e5c06be 100644 --- a/test/chains/stellar/announcements.test.ts +++ b/test/chains/stellar/announcements.test.ts @@ -1,23 +1,44 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { fetchAnnouncements, RetentionExceededError, } from '../../../src/chains/stellar/announcements'; +import type { Announcement } from '../../../src/chains/stellar/types'; -const sorobanUrl = 'https://soroban-testnet.stellar.org'; -const horizonUrl = 'https://horizon-testnet.stellar.org'; +vi.mock('@stellar/stellar-sdk', () => { + const mockAddress = { toString: () => 'GMOCKADDRESS000000000000000000000000000000000000000000000' }; + const makeScVal = (overrides: Record = {}) => ({ + u32: () => 1, + address: () => ({}), + vec: () => [ + { address: () => ({}) }, + { bytes: () => new Uint8Array(32).fill(1) }, + { bytes: () => new Uint8Array(1).fill(0x42) }, + ], + ...overrides, + }); -type FetchCall = { - url: string; - body?: any; -}; + return { + xdr: { + ScVal: { + fromXDR: vi.fn((_data: string, _enc: string) => makeScVal()), + }, + }, + Address: { + fromScAddress: vi.fn(() => mockAddress), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers shared by HEAD-style tests (fetchAnnouncements with options) +// --------------------------------------------------------------------------- +type FetchCall = { url: string; body?: any }; const calls: FetchCall[] = []; function jsonResponse(body: unknown) { - return Promise.resolve({ - json: () => Promise.resolve(body), - } as Response); + return Promise.resolve({ json: () => Promise.resolve(body) } as Response); } function mockFetch(handler: (url: string, body?: any) => unknown) { @@ -59,6 +80,48 @@ afterEach(() => { calls.length = 0; }); +// --------------------------------------------------------------------------- +// Helpers for streaming tests (fetchAnnouncementsStream) +// --------------------------------------------------------------------------- + +function makeProbeSuccess() { + return { result: { events: [{ topic: ['', '', ''], value: '' }] } }; +} + +function makeProbeRangeError(oldest: number, latest: number) { + return { error: { message: `range: ${oldest} - ${latest}` } }; +} + +function makeProbeUnknownError() { + return { error: { message: 'some unknown error' } }; +} + +function makeEventsPage(count: number, cursor?: string) { + const events = Array.from({ length: count }, (_, i) => ({ + topic: [`topic0_${i}`, `topic1_${i}`, `topic2_${i}`], + value: `value_${i}`, + })); + return { result: { events, cursor } }; +} + +function mockFetchSequence(responses: unknown[]) { + let call = 0; + return vi.fn(async () => { + const body = responses[call++] ?? responses[responses.length - 1]; + return { json: async () => body } as Response; + }); +} + +async function collectStream(gen: AsyncGenerator): Promise { + const out: Announcement[] = []; + for await (const a of gen) out.push(a); + return out; +} + +// --------------------------------------------------------------------------- +// fetchAnnouncements with FetchAnnouncementsOptions (ledger ranges, cursors, timestamps) +// --------------------------------------------------------------------------- + describe('fetchAnnouncements Stellar ranges', () => { test('passes an explicit ledger range to Soroban getEvents', async () => { mockFetch((_url, body) => { @@ -94,6 +157,9 @@ describe('fetchAnnouncements Stellar ranges', () => { }); test('converts timestamps to inclusive and exclusive ledger bounds through Horizon', async () => { + const sorobanUrl = 'https://soroban-testnet.stellar.org'; + const horizonUrl = 'https://horizon-testnet.stellar.org'; + mockFetch((url, body) => { if (url === sorobanUrl && body?.id === 0) return sorobanRange(1, 8); if (url === `${horizonUrl}/ledgers?order=desc&limit=1`) { @@ -137,3 +203,146 @@ describe('fetchAnnouncements Stellar ranges', () => { ).rejects.toThrow('fromLedger and fromTimestamp are mutually exclusive'); }); }); + +// --------------------------------------------------------------------------- +// fetchAnnouncementsStream (streaming generator) +// --------------------------------------------------------------------------- + +describe('fetchAnnouncementsStream', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = mockFetchSequence([]); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('yields announcements from a single page', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(3)]); + vi.stubGlobal('fetch', fetchSpy); + + const results = await collectStream(fetchAnnouncementsStream()); + expect(results.length).toBe(3); + expect(results[0]).toMatchObject({ schemeId: 1 }); + }); + + test('follows cursor across multiple pages', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + fetchSpy = mockFetchSequence([ + makeProbeSuccess(), + makeEventsPage(1000, 'cursor-abc'), + makeEventsPage(5), + ]); + vi.stubGlobal('fetch', fetchSpy); + + const results = await collectStream(fetchAnnouncementsStream()); + expect(results.length).toBe(1005); + expect(fetchSpy).toHaveBeenCalledTimes(3); + + const secondPageBody = JSON.parse(fetchSpy.mock.calls[2][1].body); + expect(secondPageBody.params.pagination.cursor).toBe('cursor-abc'); + }); + + test('adjusts startLedger from probe range error', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + fetchSpy = mockFetchSequence([makeProbeRangeError(1000, 6500), makeEventsPage(2)]); + vi.stubGlobal('fetch', fetchSpy); + + const results = await collectStream(fetchAnnouncementsStream()); + expect(results.length).toBe(2); + + const pageBody = JSON.parse(fetchSpy.mock.calls[1][1].body); + expect(pageBody.params.startLedger).toBe(1500); // max(1000, 6500-5000) + }); + + test('returns empty stream on unrecoverable probe error', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + fetchSpy = mockFetchSequence([makeProbeUnknownError()]); + vi.stubGlobal('fetch', fetchSpy); + + const results = await collectStream(fetchAnnouncementsStream()); + expect(results).toHaveLength(0); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + test('stops when page has fewer than 1000 events and no cursor', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(500)]); + vi.stubGlobal('fetch', fetchSpy); + + await collectStream(fetchAnnouncementsStream()); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + test('uses sorobanUrl override', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + const customUrl = 'https://custom-rpc.example.com'; + fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(1)]); + vi.stubGlobal('fetch', fetchSpy); + + await collectStream(fetchAnnouncementsStream('stellar', customUrl)); + expect(fetchSpy.mock.calls[0][0]).toBe(customUrl); + }); + + test('cancellation: stops after yielding first item', async () => { + const { fetchAnnouncementsStream } = await import( + '../../../src/chains/stellar/announcements' + ); + + fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(1000, 'cursor-next')]); + vi.stubGlobal('fetch', fetchSpy); + + const results: Announcement[] = []; + for await (const ann of fetchAnnouncementsStream()) { + results.push(ann); + break; + } + + expect(results).toHaveLength(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); // probe + first page only + }); +}); + +// --------------------------------------------------------------------------- +// fetchAnnouncements (simple array wrapper) +// --------------------------------------------------------------------------- + +describe('fetchAnnouncements (wrapper)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test('returns all announcements as array', async () => { + const { fetchAnnouncements: fetchAll } = await import( + '../../../src/chains/stellar/announcements' + ); + + vi.stubGlobal('fetch', mockFetchSequence([makeProbeSuccess(), makeEventsPage(7)])); + + const results = await fetchAll(); + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(7); + }); +}); diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index 4cbbc5b..e0a8dbf 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -1,13 +1,31 @@ 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 { + checkStealthAddress, + scanAnnouncements, + scanAnnouncementsStream, +} 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'; +import type { Announcement, MatchedAnnouncement } from '../../../src/chains/stellar/types'; const testSig = new Uint8Array(64).fill(0xaa); +async function* announcementsFrom( + items: Announcement[], +): AsyncGenerator { + for (const item of items) yield item; +} + +async function collectStream( + stream: AsyncGenerator, +): Promise { + const results: MatchedAnnouncement[] = []; + for await (const item of stream) results.push(item); + return results; +} + describe('checkStealthAddress', () => { test('matches own announcement', () => { const keys = deriveStealthKeys(testSig); @@ -145,3 +163,225 @@ describe('scanAnnouncements', () => { expect(matched[0].stealthAddress).toBe(stealth.stealthAddress); }); }); + +describe('scanAnnouncementsStream', () => { + test('finds matching payment', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ), + ); + + expect(matched).toHaveLength(1); + expect(matched[0].stealthAddress).toBe(stealth.stealthAddress); + expect(typeof matched[0].stealthPrivateScalar).toBe('bigint'); + expect(matched[0].stealthPubKeyBytes).toBeInstanceOf(Uint8Array); + }); + + test('skips wrong scheme ID', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const announcements: Announcement[] = [ + { + schemeId: 99, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ), + ); + + expect(matched).toHaveLength(0); + }); + + test('filters mix of own and foreign announcements', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const otherSig = new Uint8Array(64).fill(0xbb); + const otherKeys = deriveStealthKeys(otherSig); + const otherStealth = generateStealthAddress(otherKeys.spendingPubKey, otherKeys.viewingPubKey); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + }, + { + schemeId: SCHEME_ID, + stealthAddress: otherStealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(otherStealth.ephemeralPubKey), + metadata: otherStealth.viewTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ), + ); + + expect(matched).toHaveLength(1); + expect(matched[0].stealthAddress).toBe(stealth.stealthAddress); + }); + + test('cancellation: stops cleanly after first match and signals source', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const ann: Announcement = { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + }; + + let sourceStopped = false; + async function* infiniteAnnouncements(): AsyncGenerator { + try { + while (true) yield ann; + } finally { + sourceStopped = true; + } + } + + const results: MatchedAnnouncement[] = []; + for await (const match of scanAnnouncementsStream( + infiniteAnnouncements(), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + )) { + results.push(match); + break; + } + + expect(results).toHaveLength(1); + expect(sourceStopped).toBe(true); + }); + + test('custom window: processes all announcements with window=1', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const announcements: Announcement[] = Array.from({ length: 10 }, () => ({ + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + })); + + const matched = await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + { window: 1 }, + ), + ); + + expect(matched).toHaveLength(10); + }); + + test('custom window: processes all announcements with window=200', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const announcements: Announcement[] = Array.from({ length: 150 }, () => ({ + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + })); + + const matched = await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + { window: 200 }, + ), + ); + + expect(matched).toHaveLength(150); + }); + + test('memory bounded: 100k announcements use < 10x memory of 1k', async () => { + const keys = deriveStealthKeys(testSig); + + async function* makeStream(count: number): AsyncGenerator { + for (let i = 0; i < count; i++) { + yield { + schemeId: 99, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: '00'.repeat(32), + metadata: '00', + }; + } + } + + async function measureHeapDelta(count: number): Promise { + if (typeof (global as { gc?: () => void }).gc === 'function') { + (global as { gc?: () => void }).gc!(); + } + const before = process.memoryUsage().heapUsed; + // window=64 (default) — peak memory O(64) regardless of total count + for await (const _ of scanAnnouncementsStream( + makeStream(count), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + )) { + // no matches expected — schemeId=99 filtered immediately + } + if (typeof (global as { gc?: () => void }).gc === 'function') { + (global as { gc?: () => void }).gc!(); + } + return Math.max(process.memoryUsage().heapUsed - before, 1); + } + + const mem1k = await measureHeapDelta(1_000); + const mem100k = await measureHeapDelta(100_000); + + expect(mem100k).toBeLessThan(Math.max(mem1k * 10, 5 * 1024 * 1024)); + }, 30_000); +});