From 0ccc4ad367c4ad496086d5faa784fcbca1fd9ed4 Mon Sep 17 00:00:00 2001 From: wheval Date: Tue, 2 Jun 2026 12:37:12 +0100 Subject: [PATCH] fix(stellar): query announcer buckets via Soroban RPC topic filters Rebase onto develop and integrate v2 bucketed getEvents filters without reverting existing range filtering, streaming, or JSDoc. Adds event-filters helpers, dual v1/v2 ingestion in fetchAnnouncements, and v2 scheme support in scanAnnouncements. Closes wraith-protocol/contracts#25 --- src/chains/stellar/EVENT_FETCHING.md | 73 +++++ src/chains/stellar/announcements.ts | 250 ++++++++++++------ src/chains/stellar/constants.ts | 17 +- src/chains/stellar/deployments.ts | 7 +- src/chains/stellar/event-filters.ts | 124 +++++++++ src/chains/stellar/index.ts | 29 +- src/chains/stellar/scan.ts | 6 +- src/chains/stellar/types.ts | 4 +- test/chains/stellar/announcements.test.ts | 41 ++- test/chains/stellar/event-filters.test.ts | 101 +++++++ .../stellar/parse-announcements.test.ts | 148 +++++++++++ test/chains/stellar/scan.test.ts | 28 +- 12 files changed, 717 insertions(+), 111 deletions(-) create mode 100644 src/chains/stellar/EVENT_FETCHING.md create mode 100644 src/chains/stellar/event-filters.ts create mode 100644 test/chains/stellar/event-filters.test.ts create mode 100644 test/chains/stellar/parse-announcements.test.ts diff --git a/src/chains/stellar/EVENT_FETCHING.md b/src/chains/stellar/EVENT_FETCHING.md new file mode 100644 index 0000000..1089ccd --- /dev/null +++ b/src/chains/stellar/EVENT_FETCHING.md @@ -0,0 +1,73 @@ +# Stellar announcement event fetching + +This document describes how `@wraith-protocol/sdk/chains/stellar` ingests Soroban +announcer events and the privacy trade-offs of RPC topic filtering. + +## Announcer versions + +| Version | Contract field | Topic layout | RPC bucket filter | +| ------- | -------------- | ------------------------------------------------- | ----------------- | +| v1 | `announcer` | `("announce", scheme_id, stealth_address)` | No | +| v2 | `announcerV2` | `("announce", 2, view_tag_bucket, metadata_kind)` | Yes | + +During the v1 → v2 migration window, `fetchAnnouncements()` reads from **both** +contracts when `announcerV2` is configured in deployments. + +## Bucketed `getEvents` queries (v2) + +For v2 announcements, the SDK builds Soroban RPC filters: + +``` +("announce", 2, view_tag_bucket, *) +``` + +Pass explicit buckets to avoid downloading the full v2 stream: + +```typescript +import { fetchAnnouncements, viewTagToBucket } from '@wraith-protocol/sdk/chains/stellar'; + +const bucket = viewTagToBucket(0x2a); +const announcements = await fetchAnnouncements('stellar', { + viewTagBuckets: [bucket], +}); +``` + +When `viewTagBuckets` is omitted, the SDK uses `("announce", 2, *, *)` on the v2 +contract. v1 events are always fetched without bucket filters because +`stealth_address` occupies topic slot 2 in the legacy layout. + +## Client-side validation + +RPC topic filters reduce bandwidth only. Recipients must still run +`scanAnnouncements()` to cryptographically verify each candidate event against +their viewing key. Never treat RPC-filtered events as trusted payments. + +## Privacy trade-off + +Indexing `view_tag_bucket` in a public Soroban topic leaks one byte of +correlation per payment. With 256 buckets, observers (including the RPC provider +when you pass bucket filters) can group announcements by approximate recipient +identity. + +| Strategy | Bandwidth | Query privacy | +| --------------------------------- | --------- | ------------- | +| v1 full stream | Highest | Lowest | +| v2 single-bucket filter | Lowest | Lower | +| v2 all-bucket wildcard | Medium | Medium | +| Private indexer / self-hosted RPC | Varies | Highest | + +For most users, 256 buckets is a reasonable balance. Choose single-bucket +filters when RPC egress cost dominates; prefer broader queries or a private +indexer when query pattern privacy dominates. + +## Filter batching + +Soroban RPC accepts at most five event filters per `getEvents` request. When +more than five buckets are requested, the SDK splits them into sequential +batches via `buildV2BucketEventFilterBatches()`. + +## Related issues + +- contracts [#23](https://github.com/wraith-protocol/contracts/issues/23) — topic design +- contracts [#24](https://github.com/wraith-protocol/contracts/issues/24) — v2 announcer contract +- contracts [#25](https://github.com/wraith-protocol/contracts/issues/25) — SDK bucketed fetch (this module) diff --git a/src/chains/stellar/announcements.ts b/src/chains/stellar/announcements.ts index 7a2a244..f7800e9 100644 --- a/src/chains/stellar/announcements.ts +++ b/src/chains/stellar/announcements.ts @@ -1,15 +1,15 @@ import type { Announcement } from './types'; import { bytesToHex } from './utils'; import { getDeployment } from './deployments'; +import type { StellarChainDeployment } from './deployments'; +import { + buildV1AnnouncerEventFilter, + buildV2AllBucketsEventFilter, + buildV2BucketEventFilterBatches, + type SorobanEventFilter, +} from './event-filters'; import { Address, xdr } from '@stellar/stellar-sdk'; -let stellarSdkPromise: Promise | undefined; - -function loadStellarSdk(): Promise { - stellarSdkPromise ??= import('@stellar/stellar-sdk'); - return stellarSdkPromise; -} - export interface FetchAnnouncementsOptions { /** Earliest ledger to include, inclusive. Ignored when cursor is provided. */ fromLedger?: number; @@ -21,6 +21,15 @@ export interface FetchAnnouncementsOptions { toTimestamp?: Date; /** Soroban RPC pagination cursor returned by a previous scan. */ cursor?: string; + /** + * View-tag buckets (0–255) to query on the v2 announcer via RPC topic filters. + * When omitted, all v2 buckets are fetched with `("announce", 2, *, *)`. + */ + viewTagBuckets?: number[]; + /** Fetch the legacy v1 announcer stream (default: `true`). */ + includeV1?: boolean; + /** Fetch the v2 announcer when `announcerV2` is configured (default: `true`). */ + includeV2?: boolean; } export interface FetchAnnouncementsResult { @@ -50,6 +59,10 @@ export class RetentionExceededError extends Error { * `getEvents`, handles pagination, and parses event XDR into SDK announcement * objects. * + * During the v1 → v2 transition window this function reads from **both** announcer + * deployments when configured. v2 queries use Soroban RPC topic filters; v1 always + * downloads the full announcer stream. See `EVENT_FETCHING.md` for privacy trade-offs. + * * @param chain - Deployment key from {@link DEPLOYMENTS}; defaults to `stellar`. * @param sorobanUrl - Optional Soroban RPC URL override. * @returns Parsed announcements from the selected announcer contract. @@ -96,8 +109,13 @@ export async function fetchAnnouncements( const sorobanUrl = typeof sorobanUrlOrOpts === 'string' ? sorobanUrlOrOpts : undefined; const url = sorobanUrl || deployment.sorobanUrl; const announcerContract = deployment.contracts.announcer; + const filterGroups = buildFilterGroups(deployment, opts); const all: Announcement[] = []; + if (filterGroups.length === 0) { + return returnsCursor ? { announcements: [], nextCursor: undefined } : []; + } + if (opts?.fromLedger !== undefined && opts.fromTimestamp !== undefined) { throw new Error('fromLedger and fromTimestamp are mutually exclusive'); } @@ -124,56 +142,70 @@ export async function fetchAnnouncements( let cursor = opts?.cursor; let nextCursor: string | undefined = cursor; - let hasMore = true; + const seen = new Set(); + const singleFilterGroup = filterGroups.length === 1; - while (hasMore) { - const params: Record = { - filters: [{ type: 'contract', contractIds: [announcerContract] }], - pagination: cursor ? { limit: 1000, cursor } : { limit: 1000 }, - }; + for (const filters of filterGroups) { + let hasMore = true; + let groupCursor = singleFilterGroup ? cursor : undefined; - if (!cursor) { - params.startLedger = startLedger; - } + while (hasMore) { + const params: Record = { + filters, + pagination: groupCursor ? { limit: 1000, cursor: groupCursor } : { limit: 1000 }, + }; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'getEvents', - params, - }), - }); + if (!groupCursor) { + params.startLedger = startLedger; + } - const data = await res.json(); - if (data.error?.message) { - const range = parseLedgerRange(data.error.message); - if (range && !opts?.cursor && startLedger < range.oldest) { - throw new RetentionExceededError(startLedger, range.oldest); + 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(); + if (data.error?.message) { + const range = parseLedgerRange(data.error.message); + if (range && !groupCursor && startLedger < range.oldest) { + throw new RetentionExceededError(startLedger, range.oldest); + } + break; } - break; - } - const events = data.result?.events ?? []; + const events = data.result?.events ?? []; - for (const event of events) { - const ledger = eventLedger(event); - if (toLedger !== undefined && ledger !== undefined && ledger >= toLedger) { - hasMore = false; - continue; + for (const event of events) { + const ledger = eventLedger(event); + if (toLedger !== undefined && ledger !== undefined && ledger >= toLedger) { + hasMore = false; + continue; + } + + const dedupeKey = String(event.id ?? `${event.txHash}:${JSON.stringify(event.topic)}`); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + const ann = parseAnnouncementEvent(event); + if (ann) all.push(ann); } - const ann = await parseAnnouncementEvent(event); - if (ann) all.push(ann); - } - nextCursor = data.result?.cursor ?? cursor; - if (!hasMore || events.length < 1000) { - hasMore = false; - } else { - cursor = data.result?.cursor; - if (!cursor) hasMore = false; + if (singleFilterGroup) { + nextCursor = data.result?.cursor ?? groupCursor; + } + + if (!hasMore || events.length < 1000) { + hasMore = false; + } else { + groupCursor = data.result?.cursor; + if (!groupCursor) hasMore = false; + } } } @@ -334,6 +366,30 @@ async function horizonLedger( return data; } +function buildFilterGroups( + deployment: StellarChainDeployment, + opts?: FetchAnnouncementsOptions, +): SorobanEventFilter[][] { + const includeV1 = opts?.includeV1 ?? true; + const includeV2 = opts?.includeV2 ?? true; + const announcerV2 = deployment.contracts.announcerV2; + const groups: SorobanEventFilter[][] = []; + + if (includeV1) { + groups.push([buildV1AnnouncerEventFilter(deployment.contracts.announcer)]); + } + + if (includeV2 && announcerV2) { + if (opts?.viewTagBuckets && opts.viewTagBuckets.length > 0) { + groups.push(...buildV2BucketEventFilterBatches(announcerV2, opts.viewTagBuckets)); + } else { + groups.push([buildV2AllBucketsEventFilter(announcerV2)]); + } + } + + return groups; +} + function parseLedgerRange(message: string): { oldest: number; latest: number } | undefined { const match = message.match(/range:\s*(\d+)\s*-\s*(\d+)/); if (!match) return undefined; @@ -343,40 +399,86 @@ function parseLedgerRange(message: string): { oldest: number; latest: number } | }; } -async function parseAnnouncementEvent( - event: Record, -): Promise { +/** @internal Exported for unit tests. */ +export function parseAnnouncementEvent(event: Record): Announcement | null { try { - const { xdr, Address } = await loadStellarSdk(); - - const topics = event.topic as string[]; + const topics = event.topic as string[] | undefined; if (!topics || topics.length < 3) return null; - const schemeIdScVal = xdr.ScVal.fromXDR(topics[1], 'base64'); - const stealthScVal = xdr.ScVal.fromXDR(topics[2], 'base64'); - const stealthAddress = Address.fromScAddress(stealthScVal.address()).toString(); - - const valueScVal = xdr.ScVal.fromXDR(event.value as string, 'base64'); - const valueVec = valueScVal.vec(); - if (!valueVec || valueVec.length < 3) return null; - - const caller = Address.fromScAddress(valueVec[0].address()).toString(); - const ephPubKeyBytes = valueVec[1].bytes(); - const viewTagBytes = valueVec[2].bytes(); - if (!ephPubKeyBytes || !viewTagBytes) return null; - - return { - schemeId: schemeIdScVal.u32(), - stealthAddress, - caller, - ephemeralPubKey: bytesToHex(new Uint8Array(ephPubKeyBytes)), - metadata: bytesToHex(new Uint8Array(viewTagBytes)), - }; + if (topics.length === 3) { + return parseV1AnnouncementEvent(event, topics); + } + + if (topics.length === 4) { + return parseV2AnnouncementEvent(event, topics); + } + + return null; } catch { return null; } } +function parseV1AnnouncementEvent( + event: Record, + topics: string[], +): Announcement | null { + const schemeIdScVal = xdr.ScVal.fromXDR(topics[1], 'base64'); + const stealthScVal = xdr.ScVal.fromXDR(topics[2], 'base64'); + const stealthAddress = Address.fromScAddress(stealthScVal.address()).toString(); + + const valueScVal = xdr.ScVal.fromXDR(event.value as string, 'base64'); + const valueVec = valueScVal.vec(); + if (!valueVec || valueVec.length < 3) return null; + + const caller = Address.fromScAddress(valueVec[0].address()).toString(); + const ephPubKeyBytes = valueVec[1].bytes(); + const metadataBytes = valueVec[2].bytes(); + if (!ephPubKeyBytes || !metadataBytes) return null; + + return { + schemeId: schemeIdScVal.u32(), + stealthAddress, + caller, + ephemeralPubKey: bytesToHex(new Uint8Array(ephPubKeyBytes)), + metadata: bytesToHex(new Uint8Array(metadataBytes)), + viewTagBucket: undefined, + }; +} + +function parseV2AnnouncementEvent( + event: Record, + topics: string[], +): Announcement | null { + const schemeIdScVal = xdr.ScVal.fromXDR(topics[1], 'base64'); + const bucketScVal = xdr.ScVal.fromXDR(topics[2], 'base64'); + + const valueScVal = xdr.ScVal.fromXDR(event.value as string, 'base64'); + const valueVec = valueScVal.vec(); + if (!valueVec || valueVec.length < 3) return null; + + const stealthAddress = Address.fromScAddress(valueVec[0].address()).toString(); + const ephPubKeyBytes = valueVec[1].bytes(); + const metadataBytes = valueVec[2].bytes(); + if (!ephPubKeyBytes || !metadataBytes) return null; + + const caller = + typeof event.contractId === 'string' + ? event.contractId + : typeof event.contract_id === 'string' + ? event.contract_id + : ''; + + return { + schemeId: schemeIdScVal.u32(), + stealthAddress, + caller, + ephemeralPubKey: bytesToHex(new Uint8Array(ephPubKeyBytes)), + metadata: bytesToHex(new Uint8Array(metadataBytes)), + viewTagBucket: bucketScVal.u32(), + }; +} + function eventLedger(event: Record): number | undefined { const ledger = event.ledger; return typeof ledger === 'number' ? ledger : undefined; diff --git a/src/chains/stellar/constants.ts b/src/chains/stellar/constants.ts index c15ad16..c4b8063 100644 --- a/src/chains/stellar/constants.ts +++ b/src/chains/stellar/constants.ts @@ -11,14 +11,27 @@ export const STEALTH_SIGNING_MESSAGE = 'Sign this message to generate your Wraith stealth keys.\n\nChain: Stellar\nNote: This signature is used for key derivation only and does not authorize any transaction.'; /** - * Scheme identifier used by Stellar announcements for ed25519 stealth addresses. + * Scheme identifier used by Stellar announcements for ed25519 stealth addresses (v1 layout). * - * Announcements with another scheme id are ignored by the Stellar scanner. + * Announcements with another scheme id are ignored by the Stellar scanner unless they + * use {@link SCHEME_ID_V2}. * * @see {@link scanAnnouncements} */ export const SCHEME_ID = 1; +/** Alias for {@link SCHEME_ID} — v1 announcer event schema. */ +export const SCHEME_ID_V1 = SCHEME_ID; + +/** Scheme ID for the v2 announcer with indexed view-tag bucket topics. */ +export const SCHEME_ID_V2 = 2; + +/** Soroban event topic-0 symbol for stealth announcements. */ +export const ANNOUNCE_EVENT_SYMBOL = 'announce'; + +/** Number of view-tag buckets exposed as Soroban RPC topic filters (0–255). */ +export const VIEW_TAG_BUCKET_COUNT = 256; + /** * Prefix that identifies Wraith Stellar stealth meta-addresses. * diff --git a/src/chains/stellar/deployments.ts b/src/chains/stellar/deployments.ts index 66f0a9c..888c1e9 100644 --- a/src/chains/stellar/deployments.ts +++ b/src/chains/stellar/deployments.ts @@ -17,8 +17,13 @@ export interface StellarChainDeployment { sorobanUrl: string; /** Deployed Soroban contract IDs used by Wraith on this network. */ contracts: { - /** Contract that stores stealth payment announcements. */ + /** v1 announcer — topics: ("announce", scheme_id, stealth_address). */ announcer: string; + /** + * v2 announcer — topics: ("announce", 2, view_tag_bucket, metadata_kind). + * Optional until the v2 contract is deployed (contracts issue #24). + */ + announcerV2?: string; /** Contract that resolves Wraith names to stealth meta-addresses. */ names: string; }; diff --git a/src/chains/stellar/event-filters.ts b/src/chains/stellar/event-filters.ts new file mode 100644 index 0000000..87c1901 --- /dev/null +++ b/src/chains/stellar/event-filters.ts @@ -0,0 +1,124 @@ +import { xdr } from '@stellar/stellar-sdk'; +import { ANNOUNCE_EVENT_SYMBOL, SCHEME_ID_V2, VIEW_TAG_BUCKET_COUNT } from './constants'; + +/** Soroban RPC allows at most five event filters per `getEvents` request. */ +export const MAX_RPC_EVENT_FILTERS = 5; + +/** A single positional topic matcher passed to Soroban RPC `getEvents`. */ +export type SorobanTopicMatcher = string[]; + +/** Contract event filter for Soroban RPC `getEvents`. */ +export interface SorobanEventFilter { + type: 'contract'; + contractIds: string[]; + topics?: SorobanTopicMatcher[]; +} + +/** Encodes a short symbol (`Symbol`) ScVal topic segment as base64 XDR. */ +export function encodeSymbolTopic(symbol: string): string { + return xdr.ScVal.scvSymbol(symbol).toXDR('base64'); +} + +/** Encodes a `u32` ScVal topic segment as base64 XDR. */ +export function encodeU32Topic(value: number): string { + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) { + throw new RangeError(`u32 topic value out of range: ${value}`); + } + return xdr.ScVal.scvU32(value).toXDR('base64'); +} + +/** + * Derives the v2 announcer view-tag bucket from a 1-byte view tag. + * + * Buckets align with the on-chain topic layout from contracts issue #24: + * the first metadata byte (view tag) is indexed directly as topic 2. + */ +export function viewTagToBucket(viewTag: number): number { + if (!Number.isInteger(viewTag) || viewTag < 0 || viewTag > 255) { + throw new RangeError(`view tag must be an integer in 0..255, got ${viewTag}`); + } + return viewTag; +} + +/** Validates a view-tag bucket index (0..255). */ +export function assertViewTagBucket(bucket: number): void { + if (!Number.isInteger(bucket) || bucket < 0 || bucket >= VIEW_TAG_BUCKET_COUNT) { + throw new RangeError( + `view tag bucket must be an integer in 0..${VIEW_TAG_BUCKET_COUNT - 1}, got ${bucket}`, + ); + } +} + +/** + * v1 announcer topic filter: `("announce", *, *)`. + * + * v1 events cannot be filtered by view-tag bucket at the RPC layer because + * `stealth_address` occupies topic slot 2. Clients must download the full v1 + * stream and apply cryptographic validation locally. + */ +export function buildV1AnnouncerEventFilter(contractId: string): SorobanEventFilter { + return { + type: 'contract', + contractIds: [contractId], + topics: [[encodeSymbolTopic(ANNOUNCE_EVENT_SYMBOL), '*', '*']], + }; +} + +/** + * v2 announcer bucket filter: `("announce", 2, view_tag_bucket, *)`. + * + * Restricts the RPC response to announcements whose view-tag bucket matches + * `viewTagBucket`, eliminating ~255/256 of v2 traffic for that bucket query. + */ +export function buildV2BucketEventFilter( + contractId: string, + viewTagBucket: number, +): SorobanEventFilter { + assertViewTagBucket(viewTagBucket); + return { + type: 'contract', + contractIds: [contractId], + topics: [ + [ + encodeSymbolTopic(ANNOUNCE_EVENT_SYMBOL), + encodeU32Topic(SCHEME_ID_V2), + encodeU32Topic(viewTagBucket), + '*', + ], + ], + }; +} + +/** + * v2 announcer catch-all filter: `("announce", 2, *, *)`. + * + * Returns every v2 announcement regardless of bucket. Prefer + * {@link buildV2BucketEventFilter} when the caller only needs specific buckets. + */ +export function buildV2AllBucketsEventFilter(contractId: string): SorobanEventFilter { + return { + type: 'contract', + contractIds: [contractId], + topics: [[encodeSymbolTopic(ANNOUNCE_EVENT_SYMBOL), encodeU32Topic(SCHEME_ID_V2), '*', '*']], + }; +} + +/** + * Builds v2 bucket filters in RPC-sized batches (max five filters per request). + */ +export function buildV2BucketEventFilterBatches( + contractId: string, + viewTagBuckets: number[], +): SorobanEventFilter[][] { + const uniqueBuckets = [...new Set(viewTagBuckets)]; + uniqueBuckets.forEach(assertViewTagBucket); + + const filters = uniqueBuckets.map((bucket) => buildV2BucketEventFilter(contractId, bucket)); + const batches: SorobanEventFilter[][] = []; + + for (let i = 0; i < filters.length; i += MAX_RPC_EVENT_FILTERS) { + batches.push(filters.slice(i, i + MAX_RPC_EVENT_FILTERS)); + } + + return batches; +} diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index f8a02db..34beb2a 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -1,5 +1,13 @@ export { deriveStealthKeys } from './keys'; -export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, META_ADDRESS_PREFIX } from './constants'; +export { + STEALTH_SIGNING_MESSAGE, + SCHEME_ID, + SCHEME_ID_V1, + SCHEME_ID_V2, + ANNOUNCE_EVENT_SYMBOL, + VIEW_TAG_BUCKET_COUNT, + META_ADDRESS_PREFIX, +} from './constants'; export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address'; export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth'; export { checkStealthAddress, scanAnnouncements, scanAnnouncementsStream } from './scan'; @@ -13,8 +21,25 @@ export { L, } from './scalar'; export { bytesToHex, hexToBytes } from './utils'; -export { fetchAnnouncements, fetchAnnouncementsStream, RetentionExceededError } from './announcements'; +export { + fetchAnnouncements, + fetchAnnouncementsStream, + RetentionExceededError, + parseAnnouncementEvent, +} from './announcements'; export type { FetchAnnouncementsOptions, FetchAnnouncementsResult } from './announcements'; +export { + MAX_RPC_EVENT_FILTERS, + encodeSymbolTopic, + encodeU32Topic, + viewTagToBucket, + assertViewTagBucket, + buildV1AnnouncerEventFilter, + buildV2BucketEventFilter, + buildV2AllBucketsEventFilter, + buildV2BucketEventFilterBatches, +} from './event-filters'; +export type { SorobanEventFilter, SorobanTopicMatcher } from './event-filters'; export { DEPLOYMENTS, getDeployment } from './deployments'; export type { StellarChainDeployment } from './deployments'; export type { diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index 5f41810..7fd01b1 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -1,6 +1,6 @@ import { computeSharedSecret, computeViewTag } from './stealth'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar'; -import { SCHEME_ID } from './constants'; +import { SCHEME_ID, SCHEME_ID_V2 } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; import { hexToBytes } from './utils'; @@ -39,7 +39,7 @@ export async function* scanAnnouncementsStream( if (batch.length === 0) break; for (const ann of batch) { - if (ann.schemeId !== SCHEME_ID) continue; + if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue; const metadataBytes = hexToBytes(ann.metadata); if (metadataBytes.length === 0) continue; @@ -169,7 +169,7 @@ export function scanAnnouncements( const matched: MatchedAnnouncement[] = []; for (const ann of announcements) { - if (ann.schemeId !== SCHEME_ID) continue; + if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue; const metadataBytes = hexToBytes(ann.metadata); if (metadataBytes.length === 0) continue; diff --git a/src/chains/stellar/types.ts b/src/chains/stellar/types.ts index a970f7c..1a44992 100644 --- a/src/chains/stellar/types.ts +++ b/src/chains/stellar/types.ts @@ -67,7 +67,7 @@ export interface GeneratedStealthAddress { * @see {@link fetchAnnouncements} */ export interface Announcement { - /** Scheme identifier (1 for ed25519). */ + /** Scheme identifier (1 = v1 layout, 2 = v2 bucketed layout). */ schemeId: number; /** The Stellar public key (G...) of the stealth address. */ stealthAddress: string; @@ -77,6 +77,8 @@ export interface Announcement { ephemeralPubKey: string; /** Hex-encoded metadata; the first byte is the view tag. */ metadata: string; + /** v2 RPC topic bucket (0–255); undefined for v1 announcements. */ + viewTagBucket?: number; } /** diff --git a/test/chains/stellar/announcements.test.ts b/test/chains/stellar/announcements.test.ts index e5c06be..faf1e07 100644 --- a/test/chains/stellar/announcements.test.ts +++ b/test/chains/stellar/announcements.test.ts @@ -6,7 +6,9 @@ import { import type { Announcement } from '../../../src/chains/stellar/types'; vi.mock('@stellar/stellar-sdk', () => { - const mockAddress = { toString: () => 'GMOCKADDRESS000000000000000000000000000000000000000000000' }; + const mockAddress = { + toString: () => 'GMOCKADDRESS000000000000000000000000000000000000000000000', + }; const makeScVal = (overrides: Record = {}) => ({ u32: () => 1, address: () => ({}), @@ -22,6 +24,10 @@ vi.mock('@stellar/stellar-sdk', () => { xdr: { ScVal: { fromXDR: vi.fn((_data: string, _enc: string) => makeScVal()), + scvSymbol: vi.fn((sym: string) => ({ toXDR: vi.fn(() => `sym:${sym}`) })), + scvU32: vi.fn((n: number) => ({ toXDR: vi.fn(() => `u32:${n}`) })), + scvBytes: vi.fn((bytes: Buffer) => ({ toXDR: vi.fn(() => bytes.toString('hex')) })), + scvVec: vi.fn((vec: unknown[]) => ({ toXDR: vi.fn(() => JSON.stringify(vec)) })), }, }, Address: { @@ -221,9 +227,7 @@ describe('fetchAnnouncementsStream', () => { }); test('yields announcements from a single page', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(3)]); vi.stubGlobal('fetch', fetchSpy); @@ -234,9 +238,7 @@ describe('fetchAnnouncementsStream', () => { }); test('follows cursor across multiple pages', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); fetchSpy = mockFetchSequence([ makeProbeSuccess(), @@ -254,9 +256,7 @@ describe('fetchAnnouncementsStream', () => { }); test('adjusts startLedger from probe range error', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); fetchSpy = mockFetchSequence([makeProbeRangeError(1000, 6500), makeEventsPage(2)]); vi.stubGlobal('fetch', fetchSpy); @@ -269,9 +269,7 @@ describe('fetchAnnouncementsStream', () => { }); test('returns empty stream on unrecoverable probe error', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); fetchSpy = mockFetchSequence([makeProbeUnknownError()]); vi.stubGlobal('fetch', fetchSpy); @@ -282,9 +280,7 @@ describe('fetchAnnouncementsStream', () => { }); test('stops when page has fewer than 1000 events and no cursor', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(500)]); vi.stubGlobal('fetch', fetchSpy); @@ -294,9 +290,7 @@ describe('fetchAnnouncementsStream', () => { }); test('uses sorobanUrl override', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); const customUrl = 'https://custom-rpc.example.com'; fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(1)]); @@ -307,9 +301,7 @@ describe('fetchAnnouncementsStream', () => { }); test('cancellation: stops after yielding first item', async () => { - const { fetchAnnouncementsStream } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncementsStream } = await import('../../../src/chains/stellar/announcements'); fetchSpy = mockFetchSequence([makeProbeSuccess(), makeEventsPage(1000, 'cursor-next')]); vi.stubGlobal('fetch', fetchSpy); @@ -335,9 +327,8 @@ describe('fetchAnnouncements (wrapper)', () => { }); test('returns all announcements as array', async () => { - const { fetchAnnouncements: fetchAll } = await import( - '../../../src/chains/stellar/announcements' - ); + const { fetchAnnouncements: fetchAll } = + await import('../../../src/chains/stellar/announcements'); vi.stubGlobal('fetch', mockFetchSequence([makeProbeSuccess(), makeEventsPage(7)])); diff --git a/test/chains/stellar/event-filters.test.ts b/test/chains/stellar/event-filters.test.ts new file mode 100644 index 0000000..85df5d9 --- /dev/null +++ b/test/chains/stellar/event-filters.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect } from 'vitest'; +import { xdr } from '@stellar/stellar-sdk'; +import { + ANNOUNCE_EVENT_SYMBOL, + SCHEME_ID_V2, + VIEW_TAG_BUCKET_COUNT, +} from '../../../src/chains/stellar/constants'; +import { + buildV1AnnouncerEventFilter, + buildV2AllBucketsEventFilter, + buildV2BucketEventFilter, + buildV2BucketEventFilterBatches, + encodeSymbolTopic, + encodeU32Topic, + MAX_RPC_EVENT_FILTERS, + viewTagToBucket, +} from '../../../src/chains/stellar/event-filters'; + +const V1_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; +const V2_CONTRACT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + +describe('viewTagToBucket', () => { + test('uses the view tag byte directly as the bucket index', () => { + expect(viewTagToBucket(0)).toBe(0); + expect(viewTagToBucket(42)).toBe(42); + expect(viewTagToBucket(255)).toBe(255); + }); + + test('rejects out-of-range values', () => { + expect(() => viewTagToBucket(-1)).toThrow(RangeError); + expect(() => viewTagToBucket(256)).toThrow(RangeError); + }); +}); + +describe('topic encoders', () => { + test('encodeSymbolTopic round-trips announce symbol', () => { + const encoded = encodeSymbolTopic(ANNOUNCE_EVENT_SYMBOL); + const decoded = xdr.ScVal.fromXDR(encoded, 'base64'); + expect(decoded.sym().toString()).toBe(ANNOUNCE_EVENT_SYMBOL); + }); + + test('encodeU32Topic round-trips scheme and bucket values', () => { + for (const value of [1, 2, 42, 255]) { + const encoded = encodeU32Topic(value); + expect(xdr.ScVal.fromXDR(encoded, 'base64').u32()).toBe(value); + } + }); +}); + +describe('buildV1AnnouncerEventFilter', () => { + test('targets the v1 announcer with three topic slots', () => { + const filter = buildV1AnnouncerEventFilter(V1_CONTRACT); + expect(filter).toEqual({ + type: 'contract', + contractIds: [V1_CONTRACT], + topics: [[encodeSymbolTopic(ANNOUNCE_EVENT_SYMBOL), '*', '*']], + }); + }); +}); + +describe('buildV2BucketEventFilter', () => { + test('builds ("announce", 2, view_tag_bucket, *) filter', () => { + const bucket = 77; + const filter = buildV2BucketEventFilter(V2_CONTRACT, bucket); + + expect(filter.type).toBe('contract'); + expect(filter.contractIds).toEqual([V2_CONTRACT]); + expect(filter.topics).toHaveLength(1); + + const [announce, scheme, viewTagBucket, wildcard] = filter.topics![0]; + expect(xdr.ScVal.fromXDR(announce, 'base64').sym().toString()).toBe(ANNOUNCE_EVENT_SYMBOL); + expect(xdr.ScVal.fromXDR(scheme, 'base64').u32()).toBe(SCHEME_ID_V2); + expect(xdr.ScVal.fromXDR(viewTagBucket, 'base64').u32()).toBe(bucket); + expect(wildcard).toBe('*'); + }); + + test('rejects invalid bucket indices', () => { + expect(() => buildV2BucketEventFilter(V2_CONTRACT, -1)).toThrow(RangeError); + expect(() => buildV2BucketEventFilter(V2_CONTRACT, VIEW_TAG_BUCKET_COUNT)).toThrow(RangeError); + }); +}); + +describe('buildV2AllBucketsEventFilter', () => { + test('wildcard bucket and metadata_kind slots', () => { + const filter = buildV2AllBucketsEventFilter(V2_CONTRACT); + expect(filter.topics).toEqual([ + [encodeSymbolTopic(ANNOUNCE_EVENT_SYMBOL), encodeU32Topic(SCHEME_ID_V2), '*', '*'], + ]); + }); +}); + +describe('buildV2BucketEventFilterBatches', () => { + test('deduplicates buckets and respects RPC filter limit', () => { + const buckets = [1, 2, 3, 4, 5, 6, 1]; + const batches = buildV2BucketEventFilterBatches(V2_CONTRACT, buckets); + + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(MAX_RPC_EVENT_FILTERS); + expect(batches[1]).toHaveLength(1); + }); +}); diff --git a/test/chains/stellar/parse-announcements.test.ts b/test/chains/stellar/parse-announcements.test.ts new file mode 100644 index 0000000..20e26b2 --- /dev/null +++ b/test/chains/stellar/parse-announcements.test.ts @@ -0,0 +1,148 @@ +import { describe, test, expect } from 'vitest'; +import { xdr, Address, Keypair } from '@stellar/stellar-sdk'; +import { parseAnnouncementEvent } from '../../../src/chains/stellar/announcements'; +import { SCHEME_ID, SCHEME_ID_V2 } from '../../../src/chains/stellar/constants'; +import { encodeSymbolTopic, encodeU32Topic } from '../../../src/chains/stellar/event-filters'; +import { bytesToHex } from '../../../src/chains/stellar/utils'; + +function encodeAddressTopic(address: string): string { + return Address.fromString(address).toScVal().toXDR('base64'); +} + +function encodeEventValue(addresses: string[], bytes: Uint8Array[]): string { + const vec = [ + Address.fromString(addresses[0]).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(bytes[0])), + xdr.ScVal.scvBytes(Buffer.from(bytes[1])), + ]; + return xdr.ScVal.scvVec(vec).toXDR('base64'); +} + +describe('parseAnnouncementEvent', () => { + const stealthAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const caller = Keypair.random().publicKey(); + const ephemeralPubKey = new Uint8Array(32).fill(9); + const metadata = new Uint8Array([0x2a]); + + test('parses v1 layout with stealth address in topics', () => { + const event = { + topic: [ + encodeSymbolTopic('announce'), + encodeU32Topic(SCHEME_ID), + encodeAddressTopic(stealthAddress), + ], + value: encodeEventValue([caller], [ephemeralPubKey, metadata]), + }; + + const parsed = parseAnnouncementEvent(event); + expect(parsed).toEqual({ + schemeId: SCHEME_ID, + stealthAddress, + caller, + ephemeralPubKey: bytesToHex(ephemeralPubKey), + metadata: bytesToHex(metadata), + viewTagBucket: undefined, + }); + }); + + test('parses v2 layout with view_tag_bucket in topics', () => { + const bucket = 42; + const event = { + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + topic: [ + encodeSymbolTopic('announce'), + encodeU32Topic(SCHEME_ID_V2), + encodeU32Topic(bucket), + encodeU32Topic(0), + ], + value: encodeEventValue([stealthAddress], [ephemeralPubKey, metadata]), + }; + + const parsed = parseAnnouncementEvent(event); + expect(parsed).toEqual({ + schemeId: SCHEME_ID_V2, + stealthAddress, + caller: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + ephemeralPubKey: bytesToHex(ephemeralPubKey), + metadata: bytesToHex(metadata), + viewTagBucket: bucket, + }); + }); + + test('returns null for unsupported topic counts', () => { + expect(parseAnnouncementEvent({ topic: [encodeSymbolTopic('announce')] })).toBeNull(); + expect( + parseAnnouncementEvent({ + topic: [ + encodeSymbolTopic('announce'), + encodeU32Topic(1), + encodeU32Topic(2), + encodeU32Topic(3), + encodeU32Topic(4), + ], + value: encodeEventValue( + [Keypair.random().publicKey()], + [new Uint8Array(32), new Uint8Array([0])], + ), + }), + ).toBeNull(); + }); +}); + +describe('mixed v1/v2 ingestion', () => { + test('deduplicates identical events from overlapping filter batches', () => { + const stealthAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const sharedEvent = { + id: '0000000001-0000000001', + topic: [ + encodeSymbolTopic('announce'), + encodeU32Topic(SCHEME_ID_V2), + encodeU32Topic(10), + encodeU32Topic(0), + ], + value: encodeEventValue( + [stealthAddress], + [new Uint8Array(32).fill(1), new Uint8Array([0x10])], + ), + }; + + const parsedOnce = parseAnnouncementEvent(sharedEvent); + const parsedTwice = parseAnnouncementEvent(sharedEvent); + + expect(parsedOnce).not.toBeNull(); + expect(parsedTwice).toEqual(parsedOnce); + }); + + test('parses v1 and v2 events from the same ingestion batch', () => { + const v1Stealth = Keypair.random().publicKey(); + const v2Stealth = Keypair.random().publicKey(); + + const v1 = parseAnnouncementEvent({ + topic: [ + encodeSymbolTopic('announce'), + encodeU32Topic(SCHEME_ID), + encodeAddressTopic(v1Stealth), + ], + value: encodeEventValue( + [Keypair.random().publicKey()], + [new Uint8Array(32).fill(2), new Uint8Array([0x01])], + ), + }); + + const v2 = parseAnnouncementEvent({ + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + topic: [ + encodeSymbolTopic('announce'), + encodeU32Topic(SCHEME_ID_V2), + encodeU32Topic(1), + encodeU32Topic(0), + ], + value: encodeEventValue([v2Stealth], [new Uint8Array(32).fill(3), new Uint8Array([0x01])]), + }); + + expect(v1?.schemeId).toBe(SCHEME_ID); + expect(v2?.schemeId).toBe(SCHEME_ID_V2); + expect(v1?.viewTagBucket).toBeUndefined(); + expect(v2?.viewTagBucket).toBe(1); + }); +}); diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index e0a8dbf..c5d6cdb 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -12,9 +12,7 @@ import type { Announcement, MatchedAnnouncement } from '../../../src/chains/stel const testSig = new Uint8Array(64).fill(0xaa); -async function* announcementsFrom( - items: Announcement[], -): AsyncGenerator { +async function* announcementsFrom(items: Announcement[]): AsyncGenerator { for (const item of items) yield item; } @@ -105,6 +103,30 @@ describe('scanAnnouncements', () => { expect(matched[0].stealthPubKeyBytes).toBeInstanceOf(Uint8Array); }); + test('accepts v2 scheme ID announcements', () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const announcements: Announcement[] = [ + { + schemeId: 2, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + viewTagBucket: stealth.viewTag, + }, + ]; + + const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + expect(matched).toHaveLength(1); + }); + test('skips wrong scheme ID', () => { const keys = deriveStealthKeys(testSig); const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey);