From 1a3051d2015fea518e4087dd6d1089b734e88455 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 07:36:06 +0000 Subject: [PATCH 1/4] feat(stellar): emit the v2 scheme id from announcement builders The deployed v2 announcer accepts only scheme_id 2, but the builders sent 1. ACTIVE_SCHEME_ID (= SCHEME_ID_V2) is now the single source of truth for buildStealthAnnouncement(), buildPathStealthPayment(), buildStellarSwapAndStealth(), buildAnnouncementData() and encodeAnnouncementData(). The legacy shared-secret scanner accepts v2 announcements like the other scanners, and SCHEME_ID is deprecated. A conformance test checks every builder against the announcer ABI vendored from wraith-protocol/contracts. Refs #234 --- src/chains/stellar/batch.ts | 6 +- src/chains/stellar/builders.ts | 4 +- src/chains/stellar/constants.ts | 31 ++- src/chains/stellar/index.ts | 1 + src/chains/stellar/path-payment.ts | 4 +- src/chains/stellar/scan.ts | 12 +- src/chains/stellar/swap.ts | 4 +- src/chains/stellar/tx-builder.ts | 4 +- .../stellar/announcer-conformance.test.ts | 202 ++++++++++++++++++ test/chains/stellar/batch.test.ts | 9 +- .../fixtures/stealth-announcer-v2.json | 49 +++++ test/chains/stellar/tx-builder.test.ts | 5 +- 12 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 test/chains/stellar/announcer-conformance.test.ts create mode 100644 test/chains/stellar/fixtures/stealth-announcer-v2.json diff --git a/src/chains/stellar/batch.ts b/src/chains/stellar/batch.ts index 56d8da4..b451cb2 100644 --- a/src/chains/stellar/batch.ts +++ b/src/chains/stellar/batch.ts @@ -7,7 +7,7 @@ import { StrKey, } from '@stellar/stellar-sdk'; import { hexToBytes } from './utils'; -import { SCHEME_ID } from './constants'; +import { ACTIVE_SCHEME_ID } from './constants'; const MAX_OPS_PER_TX = 100; const OPS_PER_PAYMENT = 2; @@ -59,13 +59,13 @@ export interface BuildResult { * storing in a Stellar manageData operation value (max 64 bytes). * * Format (34 bytes): - * [0] — schemeId (1 byte) + * [0] — scheme id, {@link ACTIVE_SCHEME_ID} (1 byte) * [1..32] — ephemeral public key (32 bytes) * [33] — view tag (1 byte) */ export function encodeAnnouncementData(ephemeralPubKey: Uint8Array, viewTag: number): Uint8Array { const data = new Uint8Array(1 + 32 + 1); - data[0] = SCHEME_ID; + data[0] = ACTIVE_SCHEME_ID; data.set(ephemeralPubKey, 1); data[33] = viewTag; return data; diff --git a/src/chains/stellar/builders.ts b/src/chains/stellar/builders.ts index 3196e97..0dba9b9 100644 --- a/src/chains/stellar/builders.ts +++ b/src/chains/stellar/builders.ts @@ -9,7 +9,7 @@ import { nativeToScVal, xdr, } from '@stellar/stellar-sdk'; -import { SCHEME_ID } from './constants'; +import { ACTIVE_SCHEME_ID } from './constants'; import { GeneratedStealthAddress } from './types'; /** @@ -312,7 +312,7 @@ export function buildStealthAnnouncement(options: BuildAnnouncementOptions) { .addOperation( contract.call( 'announce', - nativeToScVal(SCHEME_ID, { type: 'u32' }), + nativeToScVal(ACTIVE_SCHEME_ID, { type: 'u32' }), new Address(stealthResult.stealthAddress).toScVal(), xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)), xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])), diff --git a/src/chains/stellar/constants.ts b/src/chains/stellar/constants.ts index c4b8063..32271c8 100644 --- a/src/chains/stellar/constants.ts +++ b/src/chains/stellar/constants.ts @@ -11,21 +11,40 @@ 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 (v1 layout). + * Scheme identifier of the v1 Stellar announcement layout. * - * Announcements with another scheme id are ignored by the Stellar scanner unless they - * use {@link SCHEME_ID_V2}. + * @deprecated The name reads like the scheme the SDK emits, but it is the v1 value. Use + * {@link SCHEME_ID_V1} for the legacy layout, or {@link ACTIVE_SCHEME_ID} for the scheme the + * announcement builders emit. * * @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 of the v1 Stellar announcement layout. The v1 announcer emits + * `("announce", scheme_id, stealth_address)` topics and accepts any scheme id. + */ +export const SCHEME_ID_V1 = 1; -/** Scheme ID for the v2 announcer with indexed view-tag bucket topics. */ +/** + * Scheme id of the v2 announcer, which emits indexed view-tag bucket topics and accepts only + * this value. + */ export const SCHEME_ID_V2 = 2; +/** + * Scheme id the Stellar announcement builders emit, and the single source of truth for it: + * `buildStealthAnnouncement()`, `buildPathStealthPayment()`, `buildStellarSwapAndStealth()`, + * `buildAnnouncementData()` and `encodeAnnouncementData()` all read it. + * + * It is {@link SCHEME_ID_V2}, the only value the deployed v2 announcer accepts + * (`STELLAR_V2_SCHEME_ID` in wraith-protocol/contracts). The legacy v1 announcer accepts any + * scheme id, so the same calls also work against a v1 deployment. The scanners accept both + * {@link SCHEME_ID_V1} and {@link SCHEME_ID_V2}. + */ +export const ACTIVE_SCHEME_ID = SCHEME_ID_V2; + /** Soroban event topic-0 symbol for stealth announcements. */ export const ANNOUNCE_EVENT_SYMBOL = 'announce'; diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 83abb13..6a12139 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -14,6 +14,7 @@ export { SCHEME_ID, SCHEME_ID_V1, SCHEME_ID_V2, + ACTIVE_SCHEME_ID, ANNOUNCE_EVENT_SYMBOL, VIEW_TAG_BUCKET_COUNT, META_ADDRESS_PREFIX, diff --git a/src/chains/stellar/path-payment.ts b/src/chains/stellar/path-payment.ts index 90b24cd..b5f55a0 100644 --- a/src/chains/stellar/path-payment.ts +++ b/src/chains/stellar/path-payment.ts @@ -9,7 +9,7 @@ import { nativeToScVal, xdr, } from '@stellar/stellar-sdk'; -import { SCHEME_ID } from './constants'; +import { ACTIVE_SCHEME_ID } from './constants'; import { generateStealthAddress } from './stealth'; import { decodeStealthMetaAddress } from './meta-address'; import { getDeployment } from './deployments'; @@ -208,7 +208,7 @@ export function buildPathStealthPayment( builder.addOperation( contract.call( 'announce', - nativeToScVal(SCHEME_ID, { type: 'u32' }), + nativeToScVal(ACTIVE_SCHEME_ID, { type: 'u32' }), new Address(stealthResult.stealthAddress).toScVal(), xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)), xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])), diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index bb5259d..242eca8 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -2,7 +2,7 @@ import { ed25519 } from '@noble/curves/ed25519'; import type { ExtPointType } from '@noble/curves/abstract/edwards'; import { computeAnnouncementViewTag, computeSharedSecret, computeViewTag } from './stealth'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar'; -import { SCHEME_ID, SCHEME_ID_V2 } from './constants'; +import { SCHEME_ID_V1, SCHEME_ID_V2 } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; import { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address'; import { hexToBytes } from './utils'; @@ -115,7 +115,7 @@ export async function* scanAnnouncementsStream( try { for await (const ann of piped) { scanned++; - if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue; + if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue; const metadataBytes = hexToBytes(ann.metadata); if (metadataBytes.length === 0) continue; @@ -235,7 +235,7 @@ export async function* scanAnnouncementsStreamSequential( if (batch.length === 0) break; for (const ann of batch) { - if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue; + if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue; const viewTag = readViewTag(ann.metadata); if (viewTag === null) continue; @@ -426,7 +426,7 @@ export function scanAnnouncements( for (let i = 0, len = announcements.length; i < len; i++) { const ann = announcements[i]; - if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue; + if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue; const viewTag = readViewTag(ann.metadata); if (viewTag === null) continue; @@ -466,7 +466,7 @@ export function scanAnnouncements( /** * Pre-optimization scanner retained for benchmarks and migration analysis. * - * This matches the old Stellar path: every same-scheme announcement pays for + * This matches the old Stellar path: every v1 or v2 announcement pays for * X25519 first, computes the legacy shared-secret tag second, and only then * compares the announced stealth address. */ @@ -479,7 +479,7 @@ export function scanAnnouncementsLegacySharedSecretTag( const matched: MatchedAnnouncement[] = []; for (const ann of announcements) { - if (ann.schemeId !== SCHEME_ID) continue; + if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue; const metadataBytes = hexToBytes(ann.metadata); if (metadataBytes.length === 0) continue; diff --git a/src/chains/stellar/swap.ts b/src/chains/stellar/swap.ts index c0b3393..d542c2f 100644 --- a/src/chains/stellar/swap.ts +++ b/src/chains/stellar/swap.ts @@ -9,7 +9,7 @@ import { nativeToScVal, xdr, } from '@stellar/stellar-sdk'; -import { SCHEME_ID } from './constants'; +import { ACTIVE_SCHEME_ID } from './constants'; import { generateStealthAddress } from './stealth'; import { decodeStealthMetaAddress } from './meta-address'; import type { GeneratedStealthAddress } from './types'; @@ -204,7 +204,7 @@ export function buildStellarSwapAndStealth( builder.addOperation( contract.call( 'announce', - nativeToScVal(SCHEME_ID, { type: 'u32' }), + nativeToScVal(ACTIVE_SCHEME_ID, { type: 'u32' }), new Address(stealthResult.stealthAddress).toScVal(), xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)), xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])), diff --git a/src/chains/stellar/tx-builder.ts b/src/chains/stellar/tx-builder.ts index 364a499..304eb63 100644 --- a/src/chains/stellar/tx-builder.ts +++ b/src/chains/stellar/tx-builder.ts @@ -1,7 +1,7 @@ import { decodeStealthMetaAddress } from './meta-address'; import { generateStealthAddress } from './stealth'; import { bytesToHex } from './utils'; -import { SCHEME_ID } from './constants'; +import { ACTIVE_SCHEME_ID } from './constants'; import type { GeneratedStealthAddress, StealthPayment, @@ -228,7 +228,7 @@ export function buildAnnouncementData( metadata: string; }> { return stealthAddresses.map((stealth) => ({ - schemeId: SCHEME_ID, + schemeId: ACTIVE_SCHEME_ID, stealthAddress: stealth.stealthAddress, caller, ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), diff --git a/test/chains/stellar/announcer-conformance.test.ts b/test/chains/stellar/announcer-conformance.test.ts new file mode 100644 index 0000000..9c23002 --- /dev/null +++ b/test/chains/stellar/announcer-conformance.test.ts @@ -0,0 +1,202 @@ +/** + * Builder-to-contract conformance for the Stellar announcer. + * + * Every announcement the SDK builds must be a valid call of the deployed v2 announcer, as described + * by the vendored fixture in `fixtures/stealth-announcer-v2.json` (the contract ABI plus the + * scheme id and metadata rules that `announce()` enforces). + */ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { Address, Asset, Keypair, Networks, Operation, xdr } from '@stellar/stellar-sdk'; +import { ACTIVE_SCHEME_ID, SCHEME_ID_V2 } from '../../../src/chains/stellar/constants'; +import { buildStealthAnnouncement } from '../../../src/chains/stellar/builders'; +import { buildPathStealthPayment } from '../../../src/chains/stellar/path-payment'; +import { buildStellarSwapAndStealth } from '../../../src/chains/stellar/swap'; +import { buildAnnouncementData } from '../../../src/chains/stellar/tx-builder'; +import { decodeAnnouncementData, encodeAnnouncementData } from '../../../src/chains/stellar/batch'; +import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; +import { encodeStealthMetaAddress } from '../../../src/chains/stellar/meta-address'; +import { generateStealthAddress } from '../../../src/chains/stellar/stealth'; + +interface AbiInput { + name: string; + type_: unknown; +} + +interface AnnouncerFixture { + schemeId: number; + metadata: { minLength: number; viewTagIndex: number }; + abi: Array<{ function_v0?: { name: string; inputs: AbiInput[] } }>; +} + +const fixture = JSON.parse( + readFileSync(new URL('./fixtures/stealth-announcer-v2.json', import.meta.url), 'utf8'), +) as AnnouncerFixture; + +const announceSpec = fixture.abi.find( + (entry) => entry.function_v0?.name === 'announce', +)?.function_v0; + +const ANNOUNCER = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; +const NETWORK = Networks.TESTNET; +const USDC = new Asset('USDC', Keypair.random().publicKey()); + +const recipientKeys = deriveStealthKeys(new Uint8Array(64).fill(0xab)); +const recipientMeta = encodeStealthMetaAddress( + recipientKeys.spendingPubKey, + recipientKeys.viewingPubKey, +); + +const base = { + sender: Keypair.random().publicKey(), + sequence: '100', + announcerContract: ANNOUNCER, + networkPassphrase: NETWORK, + recipientMeta, + _ephemeralSeed: new Uint8Array(32).fill(0xee), +}; + +/** Whether `value` has the Soroban type the ABI declares for an argument. */ +function matchesAbiType(type: unknown, value: xdr.ScVal): boolean { + switch (type) { + case 'u32': + return value.switch() === xdr.ScValType.scvU32(); + case 'address': + return value.switch() === xdr.ScValType.scvAddress(); + case 'bytes': + return value.switch() === xdr.ScValType.scvBytes(); + } + if (typeof type === 'object' && type !== null && 'bytes_n' in type) { + const { n } = (type as { bytes_n: { n: number } }).bytes_n; + return value.switch() === xdr.ScValType.scvBytes() && value.bytes().length === n; + } + throw new Error(`The conformance test does not handle ABI type ${JSON.stringify(type)}`); +} + +/** Decodes the contract call of an invokeHostFunction operation. */ +function decodeCall(op: Operation) { + expect(op.type).toBe('invokeHostFunction'); + const call = (op as Operation.InvokeHostFunction).func.invokeContract(); + return { + contract: Address.fromScAddress(call.contractAddress()).toString(), + functionName: call.functionName().toString(), + args: call.args(), + }; +} + +interface StealthFields { + stealthAddress: string; + ephemeralPubKey: Uint8Array; + viewTag: number; +} + +const builders: Array<{ name: string; build: () => { op: Operation; stealth: StealthFields } }> = [ + { + name: 'buildStealthAnnouncement', + build: () => { + const stealth = generateStealthAddress( + recipientKeys.spendingPubKey, + recipientKeys.viewingPubKey, + base._ephemeralSeed, + ); + const tx = buildStealthAnnouncement({ + sender: base.sender, + sequence: base.sequence, + stealthResult: stealth, + announcerContract: ANNOUNCER, + networkPassphrase: NETWORK, + }); + return { op: tx.operations[0], stealth }; + }, + }, + { + name: 'buildPathStealthPayment', + build: () => { + const { transaction, stealthResult } = buildPathStealthPayment({ + ...base, + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + sendMax: '50.025', + }); + return { + op: transaction.operations[transaction.operations.length - 1], + stealth: stealthResult, + }; + }, + }, + { + name: 'buildStellarSwapAndStealth', + build: () => { + const { transaction, stealthResult } = buildStellarSwapAndStealth({ + ...base, + fromAsset: USDC, + toAsset: Asset.native(), + destAmount: '100', + sendMax: '50.025', + }); + return { + op: transaction.operations[transaction.operations.length - 1], + stealth: stealthResult, + }; + }, + }, +]; + +describe('Stellar announcer conformance fixture', () => { + it('describes announce() with the v2 scheme id', () => { + expect(announceSpec).toBeDefined(); + expect(fixture.schemeId).toBe(SCHEME_ID_V2); + }); + + it('matches the scheme id the SDK emits', () => { + expect(ACTIVE_SCHEME_ID).toBe(fixture.schemeId); + }); +}); + +describe.each(builders)('$name builds a valid announce() call', ({ build }) => { + it('calls announce on the announcer contract with the ABI argument types', () => { + const { op } = build(); + const { contract, functionName, args } = decodeCall(op); + + expect(contract).toBe(ANNOUNCER); + expect(functionName).toBe(announceSpec!.name); + expect(args).toHaveLength(announceSpec!.inputs.length); + announceSpec!.inputs.forEach((input, index) => { + expect(matchesAbiType(input.type_, args[index]), input.name).toBe(true); + }); + }); + + it('passes the scheme id, stealth address, ephemeral key and view tag the contract expects', () => { + const { op, stealth } = build(); + const [schemeId, stealthAddress, ephemeralPubKey, metadata] = decodeCall(op).args; + + expect(schemeId.u32()).toBe(fixture.schemeId); + expect(Address.fromScVal(stealthAddress).toString()).toBe(stealth.stealthAddress); + expect(new Uint8Array(ephemeralPubKey.bytes())).toEqual( + new Uint8Array(stealth.ephemeralPubKey), + ); + + const metadataBytes = new Uint8Array(metadata.bytes()); + expect(metadataBytes.length).toBeGreaterThanOrEqual(fixture.metadata.minLength); + expect(metadataBytes[fixture.metadata.viewTagIndex]).toBe(stealth.viewTag); + }); +}); + +describe('announcement data helpers', () => { + it('buildAnnouncementData emits the contract scheme id', () => { + const stealth = generateStealthAddress( + recipientKeys.spendingPubKey, + recipientKeys.viewingPubKey, + base._ephemeralSeed, + ); + const [data] = buildAnnouncementData([stealth], base.sender); + expect(data.schemeId).toBe(fixture.schemeId); + }); + + it('encodeAnnouncementData writes the contract scheme id into byte 0', () => { + const encoded = encodeAnnouncementData(new Uint8Array(32).fill(7), 0x2a); + expect(encoded[0]).toBe(fixture.schemeId); + expect(decodeAnnouncementData(encoded).schemeId).toBe(fixture.schemeId); + }); +}); diff --git a/test/chains/stellar/batch.test.ts b/test/chains/stellar/batch.test.ts index 12f382e..ccf4033 100644 --- a/test/chains/stellar/batch.test.ts +++ b/test/chains/stellar/batch.test.ts @@ -6,7 +6,7 @@ import { decodeAnnouncementData, } from '../../../src/chains/stellar/batch'; import type { StealthPaymentConfig, BatchConfig } from '../../../src/chains/stellar/batch'; -import { SCHEME_ID } from '../../../src/chains/stellar/constants'; +import { ACTIVE_SCHEME_ID, SCHEME_ID_V2 } from '../../../src/chains/stellar/constants'; const SOURCE = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; const DEST = 'GCVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVH7N'; @@ -40,10 +40,11 @@ describe('encodeAnnouncementData / decodeAnnouncementData', () => { test('roundtrip', () => { const encoded = encodeAnnouncementData(ephBytes, VIEW_TAG); expect(encoded.length).toBe(34); - expect(encoded[0]).toBe(SCHEME_ID); + expect(encoded[0]).toBe(ACTIVE_SCHEME_ID); + expect(encoded[0]).toBe(SCHEME_ID_V2); const decoded = decodeAnnouncementData(encoded); - expect(decoded.schemeId).toBe(SCHEME_ID); + expect(decoded.schemeId).toBe(ACTIVE_SCHEME_ID); expect(Buffer.from(decoded.ephemeralPubKey).toString('hex')).toBe(EPH_PUB_KEY); expect(decoded.viewTag).toBe(VIEW_TAG); }); @@ -56,7 +57,7 @@ describe('encodeAnnouncementData / decodeAnnouncementData', () => { expect(encoded.length).toBe(34); const decoded = decodeAnnouncementData(encoded); - expect(decoded.schemeId).toBe(SCHEME_ID); + expect(decoded.schemeId).toBe(ACTIVE_SCHEME_ID); expect(Buffer.from(decoded.ephemeralPubKey).toString('hex')).toBe(eph); expect(decoded.viewTag).toBe(tag); }); diff --git a/test/chains/stellar/fixtures/stealth-announcer-v2.json b/test/chains/stellar/fixtures/stealth-announcer-v2.json new file mode 100644 index 0000000..0fbac0a --- /dev/null +++ b/test/chains/stellar/fixtures/stealth-announcer-v2.json @@ -0,0 +1,49 @@ +{ + "$comment": "Contract side of the Stellar announcer conformance test. `abi` is copied verbatim from wraith-protocol/contracts stellar/abi/stealth_announcer.json at `source.commit`. `schemeId` is STELLAR_V2_SCHEME_ID, which announce() asserts, and `metadata` is the rule that announce() reads the view tag bucket from metadata[0]. Refresh all three when the contract changes.", + "source": { + "repository": "wraith-protocol/contracts", + "commit": "66273f272c7f4c5ad0dce7542fe9cb41d11e9ca9", + "abi": "stellar/abi/stealth_announcer.json", + "contract": "stellar/stealth-announcer/src/lib.rs" + }, + "schemeId": 2, + "metadata": { + "minLength": 1, + "viewTagIndex": 0 + }, + "abi": [ + { + "function_v0": { + "doc": "Emits a Stellar v2 stealth address announcement event.\\n\\nThis is a pure event-emission function with no access control and no\\nstorage. Indexers watch for these events to let recipients detect\\nincoming payments.\\n\\nv2 event shape:\\n* topics: `(\"announce\", scheme_id, view_tag_bucket, metadata_kind)`\\n* data: `(stealth_address, ephemeral_pub_key, metadata)`\\n\\nThe stable `view_tag_bucket` derivation is `metadata[0] as u32`, where\\n`metadata_kind = 1` (`METADATA_KIND_VIEW_TAG`) means the first metadata\\nbyte is the view tag and the remaining bytes are scheme-specific. This\\nlets wallets and indexers filter Stellar RPC `getEvents` by scheme and\\nbucket before doing client-side cryptographic validation.\\n\\nMigration note: v1 announcements used the old Stellar layout\\n`(\"announce\", scheme_id, stealth_address)` with\\n`(caller, ephemeral_pub_key, metadata)`. Do not reinterpret historical v1\\nevents as v2. The compatibility path is a new announcer deployment using\\n`scheme_id = 2`.\\n\\n# Arguments\\n* `scheme_id` - Must be `2` for the v2 St", + "name": "announce", + "inputs": [ + { + "doc": "", + "name": "scheme_id", + "type_": "u32" + }, + { + "doc": "", + "name": "stealth_address", + "type_": "address" + }, + { + "doc": "", + "name": "ephemeral_pub_key", + "type_": { + "bytes_n": { + "n": 32 + } + } + }, + { + "doc": "", + "name": "metadata", + "type_": "bytes" + } + ], + "outputs": [] + } + } + ] +} diff --git a/test/chains/stellar/tx-builder.test.ts b/test/chains/stellar/tx-builder.test.ts index f305716..580d5d7 100644 --- a/test/chains/stellar/tx-builder.test.ts +++ b/test/chains/stellar/tx-builder.test.ts @@ -10,6 +10,7 @@ import { DEFAULT_BATCH_SENDER_THRESHOLD, } from '../../../src/chains/stellar/tx-builder'; import type { StealthPayment } from '../../../src/chains/stellar/types'; +import { ACTIVE_SCHEME_ID } from '../../../src/chains/stellar/constants'; const testSig = new Uint8Array(64).fill(0xaa); const networkPassphrase = 'Test SDF Network ; September 2015'; @@ -383,7 +384,7 @@ describe('tx-builder: buildAnnouncementData', () => { const announcementData = buildAnnouncementData(txResult.stealthAddresses, caller); expect(announcementData).toHaveLength(1); - expect(announcementData[0].schemeId).toBe(1); + expect(announcementData[0].schemeId).toBe(ACTIVE_SCHEME_ID); expect(announcementData[0].stealthAddress).toBe(txResult.stealthAddresses[0].stealthAddress); expect(announcementData[0].caller).toBe(caller); expect(announcementData[0].ephemeralPubKey).toMatch(/^[0-9a-f]{64}$/); @@ -408,7 +409,7 @@ describe('tx-builder: buildAnnouncementData', () => { // Verify each announcement has correct structure announcementData.forEach((ann, i) => { - expect(ann.schemeId).toBe(1); + expect(ann.schemeId).toBe(ACTIVE_SCHEME_ID); expect(ann.stealthAddress).toBe(txResult.stealthAddresses[i].stealthAddress); expect(ann.caller).toBe(caller); expect(ann.ephemeralPubKey).toMatch(/^[0-9a-f]{64}$/); From b9f03d3755367b84586b29eb749083ea32fa4ad8 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 07:36:06 +0000 Subject: [PATCH 2/4] docs(stellar): document the scheme 2 change for v1 consumers Refs #234 --- CHANGELOG.md | 4 ++++ MIGRATING.md | 25 +++++++++++++++++++++++++ README.md | 2 +- src/chains/stellar/EVENT_FETCHING.md | 4 ++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f76a35b..68ef111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. ### Changed - **Vue scanner calls take options objects** (issue #244): `fetchAnnouncements()` now accepts `{ chain, network, url, signal }` instead of a chain or URL string, and `scanAnnouncements()`, `checkAddress()` and `scanWithKeys()` accept one discriminated input object (`{ chain, announcements, keys }`, `{ chain, ...keys, viewTag }`, `{ chain, keys, ...fetchOptions }`) instead of positional arguments. This is a breaking change for consumers on `0.1.x`; the old positional signatures could not reject mismatched key material. +- **Stellar Announcements Emit Scheme 2** (issue #234): `buildStealthAnnouncement()`, `buildPathStealthPayment()`, `buildStellarSwapAndStealth()`, `buildAnnouncementData()` and `encodeAnnouncementData()` now emit scheme `2`, the only value the deployed v2 announcer accepts; they sent `1`. The new `ACTIVE_SCHEME_ID` export is the single source of truth. The v1 announcer accepts any scheme id, so v1 deployments keep working. + - `scanAnnouncementsLegacySharedSecretTag()` now accepts v2 announcements, like the other scanners. + - `SCHEME_ID` (still `1`) is deprecated in favour of `SCHEME_ID_V1` and `ACTIVE_SCHEME_ID`. + - A builder-to-contract conformance test checks every builder against the announcer ABI from wraith-protocol/contracts. See [`MIGRATING.md`](./MIGRATING.md#stellar-announcements-use-scheme-2). - **Stellar Helper Errors Use the Taxonomy** (issue #235): for a non-2xx Horizon status, `buildMultisigStealthWithdraw()` throws `RPCRequestError` instead of a plain `Error`, and an account record without its sequence, thresholds or signers throws `HorizonResponseError`. `submitOfflineStellarTransaction()` reports the full `/transactions` URL on `RPCRequestError`, throws `HorizonResponseError` for a success body that is not a JSON object, and gives up after 60 s by default; pass `{ timeouts: { requestMs: 0 } }` for the old behaviour. - **Stellar Horizon and RPC Clients Time Out by Default** (issue #202): an attempt now fails after 10 s without response headers or 30 s in total, then retries or fails over, instead of waiting indefinitely. Pass `timeouts: { connectMs: 0, requestMs: 0 }` for the old behaviour, or longer values for slow calls such as Horizon transaction submission. - **Stellar Chain Module Cryptographic Audit Fixes**: Applied all findings from independent cryptographic audit (issue #55). Breaking changes: diff --git a/MIGRATING.md b/MIGRATING.md index 82dc754..64bd7bf 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -238,6 +238,31 @@ See [Stellar Horizon and RPC request timeouts](./docs/chains/stellar-request-tim --- +### Stellar: Announcements Use Scheme 2 + +The deployed v2 announcer (`stealth-announcer` in wraith-protocol/contracts) accepts only `scheme_id = 2`, but the builders sent `1`. They now all emit `ACTIVE_SCHEME_ID`, which is `SCHEME_ID_V2` (`2`): + +- `buildStealthAnnouncement()`, `buildPathStealthPayment()` and `buildStellarSwapAndStealth()` pass it to `announce()`. +- `buildAnnouncementData()` returns it as `schemeId`, and `encodeAnnouncementData()` writes it into byte 0. + +What it means if you still use the v1 announcer or v1 events: + +- **Sending to a v1 announcer still works.** The v1 contract accepts any scheme id, so the same calls succeed. Its events keep the v1 layout `("announce", scheme_id, stealth_address)`, now with `scheme_id = 2`. +- **The scanners accept both.** `scanAnnouncements()` and the other Stellar scanners keep v1 (`1`) and v2 (`2`) announcements. If your own indexer or filter keeps only `schemeId === 1`, accept `2` as well, or it will miss new payments. +- **Old events are not reinterpreted.** The SDK picks the v1 or v2 parser from the topic layout (3 or 4 topics), not from the scheme id, so historical v1 events stay v1 events. +- **`SCHEME_ID` is deprecated.** It is still `1`. Use `SCHEME_ID_V1` when you mean the legacy layout and `ACTIVE_SCHEME_ID` when you mean the scheme the SDK emits. + +```typescript +import { SCHEME_ID_V1, SCHEME_ID_V2 } from '@wraith-protocol/sdk/chains/stellar'; + +const isStellarStealthAnnouncement = (schemeId: number) => + schemeId === SCHEME_ID_V1 || schemeId === SCHEME_ID_V2; +``` + +`test/chains/stellar/announcer-conformance.test.ts` checks every builder against the announcer ABI vendored in `test/chains/stellar/fixtures/stealth-announcer-v2.json`. + +--- + ### Scanner Pool API The SDK now includes a `ScannerPool` utility for efficient multichain scanning. While this is an **additive feature** (no breaking changes), be aware of the new export: diff --git a/README.md b/README.md index 37e029c..5879a6e 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ import { decodeStealthMetaAddress, fetchAnnouncements, RetentionExceededError, - SCHEME_ID, + ACTIVE_SCHEME_ID, bytesToHex, } from '@wraith-protocol/sdk/chains/stellar'; diff --git a/src/chains/stellar/EVENT_FETCHING.md b/src/chains/stellar/EVENT_FETCHING.md index 1089ccd..41c5e0b 100644 --- a/src/chains/stellar/EVENT_FETCHING.md +++ b/src/chains/stellar/EVENT_FETCHING.md @@ -13,6 +13,10 @@ announcer events and the privacy trade-offs of RPC topic filtering. During the v1 → v2 migration window, `fetchAnnouncements()` reads from **both** contracts when `announcerV2` is configured in deployments. +The builders emit `ACTIVE_SCHEME_ID` (`2`) to whichever announcer you pass. The v1 +contract accepts any scheme id, so v1-layout events carry scheme `1` (older SDKs) +or `2`. The parser picks the layout from the topic count, not from the scheme id. + ## Bucketed `getEvents` queries (v2) For v2 announcements, the SDK builds Soroban RPC filters: From b8ddc9b3773fa3fcd228467b631e9f792457d511 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 07:36:06 +0000 Subject: [PATCH 3/4] chore(api): update the Stellar API report for ACTIVE_SCHEME_ID --- etc/sdk-stellar.api.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/etc/sdk-stellar.api.md b/etc/sdk-stellar.api.md index dcf672a..21b2b35 100644 --- a/etc/sdk-stellar.api.md +++ b/etc/sdk-stellar.api.md @@ -15,6 +15,9 @@ import { Transaction } from '@stellar/stellar-sdk'; import { TransactionBuilder } from '@stellar/stellar-sdk'; import { xdr } from '@stellar/stellar-sdk'; +// @public +export const ACTIVE_SCHEME_ID = 2; + // @public export function addStealthMultisigSigner(tx: Transaction, signerKey: Keypair | string): Transaction; @@ -665,7 +668,7 @@ export function scanAnnouncementsStream(source: AsyncIterable, vie tracer?: Tracer; }): AsyncGenerator; -// @public +// @public @deprecated export const SCHEME_ID = 1; // @public From de8c1e87c01c3cb8460f67ef009ef8ba7f16c9b6 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sun, 18 Oct 2026 10:11:36 +0300 Subject: [PATCH 4/4] fix(stellar): keep the legacy shared-secret scanner v1-only scanAnnouncementsLegacySharedSecretTag() compares the metadata tag with computeViewTag(sharedSecret), the v1 algorithm. Scheme 2 announcements carry the public tag from computeAnnouncementViewTag(), so accepting them there missed about 255 of 256 valid v2 announcements. It reads scheme 1 only again, as before this branch, and says so in its docs. Tests: a v2 announcement from buildAnnouncementData() must match in scanAnnouncements() and scanAnnouncementsStream(), and the legacy scanner must not claim a scheme 2 announcement, including one that carries the legacy tag. CHANGELOG, MIGRATING and the ACTIVE_SCHEME_ID docs say which scanners read which schemes. --- CHANGELOG.md | 2 +- MIGRATING.md | 2 +- src/chains/stellar/constants.ts | 5 +- src/chains/stellar/scan.ts | 13 +++-- test/chains/stellar/scan.test.ts | 81 +++++++++++++++++++++++++++++++- 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ef111..1b20835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,7 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. - **Vue scanner calls take options objects** (issue #244): `fetchAnnouncements()` now accepts `{ chain, network, url, signal }` instead of a chain or URL string, and `scanAnnouncements()`, `checkAddress()` and `scanWithKeys()` accept one discriminated input object (`{ chain, announcements, keys }`, `{ chain, ...keys, viewTag }`, `{ chain, keys, ...fetchOptions }`) instead of positional arguments. This is a breaking change for consumers on `0.1.x`; the old positional signatures could not reject mismatched key material. - **Stellar Announcements Emit Scheme 2** (issue #234): `buildStealthAnnouncement()`, `buildPathStealthPayment()`, `buildStellarSwapAndStealth()`, `buildAnnouncementData()` and `encodeAnnouncementData()` now emit scheme `2`, the only value the deployed v2 announcer accepts; they sent `1`. The new `ACTIVE_SCHEME_ID` export is the single source of truth. The v1 announcer accepts any scheme id, so v1 deployments keep working. - - `scanAnnouncementsLegacySharedSecretTag()` now accepts v2 announcements, like the other scanners. + - `scanAnnouncements()` and `scanAnnouncementsStream()` accept v1 and v2 announcements. `scanAnnouncementsLegacySharedSecretTag()`, kept for benchmarks, stays v1-only: it checks the legacy shared-secret view tag, which v2 announcements do not carry. - `SCHEME_ID` (still `1`) is deprecated in favour of `SCHEME_ID_V1` and `ACTIVE_SCHEME_ID`. - A builder-to-contract conformance test checks every builder against the announcer ABI from wraith-protocol/contracts. See [`MIGRATING.md`](./MIGRATING.md#stellar-announcements-use-scheme-2). - **Stellar Helper Errors Use the Taxonomy** (issue #235): for a non-2xx Horizon status, `buildMultisigStealthWithdraw()` throws `RPCRequestError` instead of a plain `Error`, and an account record without its sequence, thresholds or signers throws `HorizonResponseError`. `submitOfflineStellarTransaction()` reports the full `/transactions` URL on `RPCRequestError`, throws `HorizonResponseError` for a success body that is not a JSON object, and gives up after 60 s by default; pass `{ timeouts: { requestMs: 0 } }` for the old behaviour. diff --git a/MIGRATING.md b/MIGRATING.md index 64bd7bf..6252082 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -248,7 +248,7 @@ The deployed v2 announcer (`stealth-announcer` in wraith-protocol/contracts) acc What it means if you still use the v1 announcer or v1 events: - **Sending to a v1 announcer still works.** The v1 contract accepts any scheme id, so the same calls succeed. Its events keep the v1 layout `("announce", scheme_id, stealth_address)`, now with `scheme_id = 2`. -- **The scanners accept both.** `scanAnnouncements()` and the other Stellar scanners keep v1 (`1`) and v2 (`2`) announcements. If your own indexer or filter keeps only `schemeId === 1`, accept `2` as well, or it will miss new payments. +- **The scanners accept both.** `scanAnnouncements()` and `scanAnnouncementsStream()` keep v1 (`1`) and v2 (`2`) announcements. If your own indexer or filter keeps only `schemeId === 1`, accept `2` as well, or it will miss new payments. The exception is `scanAnnouncementsLegacySharedSecretTag()`, kept for benchmarks: it reads v1 only, because it checks the legacy shared-secret view tag and v2 announcements use the public one (`computeAnnouncementViewTag()`). - **Old events are not reinterpreted.** The SDK picks the v1 or v2 parser from the topic layout (3 or 4 topics), not from the scheme id, so historical v1 events stay v1 events. - **`SCHEME_ID` is deprecated.** It is still `1`. Use `SCHEME_ID_V1` when you mean the legacy layout and `ACTIVE_SCHEME_ID` when you mean the scheme the SDK emits. diff --git a/src/chains/stellar/constants.ts b/src/chains/stellar/constants.ts index 32271c8..27e5f98 100644 --- a/src/chains/stellar/constants.ts +++ b/src/chains/stellar/constants.ts @@ -40,8 +40,9 @@ export const SCHEME_ID_V2 = 2; * * It is {@link SCHEME_ID_V2}, the only value the deployed v2 announcer accepts * (`STELLAR_V2_SCHEME_ID` in wraith-protocol/contracts). The legacy v1 announcer accepts any - * scheme id, so the same calls also work against a v1 deployment. The scanners accept both - * {@link SCHEME_ID_V1} and {@link SCHEME_ID_V2}. + * scheme id, so the same calls also work against a v1 deployment. `scanAnnouncements()` and + * `scanAnnouncementsStream()` accept both {@link SCHEME_ID_V1} and {@link SCHEME_ID_V2}; + * `scanAnnouncementsLegacySharedSecretTag()` reads v1 only. */ export const ACTIVE_SCHEME_ID = SCHEME_ID_V2; diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index 242eca8..f1294a3 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -466,9 +466,14 @@ export function scanAnnouncements( /** * Pre-optimization scanner retained for benchmarks and migration analysis. * - * This matches the old Stellar path: every v1 or v2 announcement pays for - * X25519 first, computes the legacy shared-secret tag second, and only then - * compares the announced stealth address. + * This matches the old Stellar path: every v1 announcement pays for X25519 + * first, computes the legacy shared-secret tag second, and only then compares + * the announced stealth address. + * + * It reads {@link SCHEME_ID_V1} announcements only. Scheme 2 announcements carry + * the public view tag from {@link computeAnnouncementViewTag}, which this path + * never computes, so it skips them; scan those with {@link scanAnnouncements} + * or {@link scanAnnouncementsStream}. */ export function scanAnnouncementsLegacySharedSecretTag( announcements: Announcement[], @@ -479,7 +484,7 @@ export function scanAnnouncementsLegacySharedSecretTag( const matched: MatchedAnnouncement[] = []; for (const ann of announcements) { - if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue; + if (ann.schemeId !== SCHEME_ID_V1) continue; const metadataBytes = hexToBytes(ann.metadata); if (metadataBytes.length === 0) continue; diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index b778834..a07911e 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -13,7 +13,8 @@ import { scanAnnouncementsStream, scanAnnouncementsLegacySharedSecretTag, } from '../../../src/chains/stellar/scan'; -import { SCHEME_ID } from '../../../src/chains/stellar/constants'; +import { SCHEME_ID, SCHEME_ID_V1, SCHEME_ID_V2 } from '../../../src/chains/stellar/constants'; +import { buildAnnouncementData } from '../../../src/chains/stellar/tx-builder'; import { bytesToHex } from '../../../src/chains/stellar/utils'; import type { Announcement, MatchedAnnouncement } from '../../../src/chains/stellar/types'; @@ -597,6 +598,84 @@ function makeRecordingTracer() { return { tracer, spanNames }; } +describe('scheme 2 announcements', () => { + const caller = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + + /** A stealth payment whose legacy shared-secret tag differs from its public view tag. */ + function paymentWithDistinctTags(keys: ReturnType) { + for (let i = 0; i < 256; i++) { + const ephemeralSeed = new Uint8Array(32).fill(0x21 + i); + const stealth = generateStealthAddress( + keys.spendingPubKey, + keys.viewingPubKey, + ephemeralSeed, + ); + const legacyTag = computeViewTag(computeSharedSecret(ephemeralSeed, keys.viewingPubKey)); + if (legacyTag !== stealth.viewTag) return { stealth, legacyTag }; + } + throw new Error('no seed with distinct tags'); + } + + test('a generated v2 announcement matches in scanAnnouncements and scanAnnouncementsStream', async () => { + const keys = deriveStealthKeys(testSig); + const { stealth } = paymentWithDistinctTags(keys); + const [announcement] = buildAnnouncementData([stealth], caller); + expect(announcement.schemeId).toBe(SCHEME_ID_V2); + + const matched = scanAnnouncements( + [announcement], + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + expect(matched).toHaveLength(1); + expect(matched[0].stealthAddress).toBe(stealth.stealthAddress); + expect(matched[0].schemeId).toBe(SCHEME_ID_V2); + + const streamed = await collectStream( + scanAnnouncementsStream( + announcementsFrom([announcement]), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ), + ); + expect(streamed.map((m) => m.stealthAddress)).toEqual([stealth.stealthAddress]); + expect(streamed[0].stealthPrivateScalar).toBe(matched[0].stealthPrivateScalar); + }); + + test('scanAnnouncementsLegacySharedSecretTag reads v1 only and never claims a scheme 2 announcement', () => { + const keys = deriveStealthKeys(testSig); + const { stealth, legacyTag } = paymentWithDistinctTags(keys); + const legacyV1: Announcement = { + schemeId: SCHEME_ID_V1, + stealthAddress: stealth.stealthAddress, + caller, + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: legacyTag.toString(16).padStart(2, '0'), + }; + const scan = (announcements: Announcement[]) => + scanAnnouncementsLegacySharedSecretTag( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + // A v1 announcement with the legacy tag still matches on the legacy path. + expect(scan([legacyV1]).map((m) => m.stealthAddress)).toEqual([stealth.stealthAddress]); + // The same bytes under scheme 2 are not a valid v2 announcement, and the + // legacy path must not accept them through the v1 tag algorithm. + expect(scan([{ ...legacyV1, schemeId: SCHEME_ID_V2 }])).toHaveLength(0); + // A real v2 announcement is skipped here; scanAnnouncements() is the scanner for it. + const [v2] = buildAnnouncementData([stealth], caller); + expect(scan([v2])).toHaveLength(0); + expect( + scanAnnouncements([v2], keys.viewingKey, keys.spendingPubKey, keys.spendingScalar), + ).toHaveLength(1); + }); +}); + describe('scanAnnouncementsStream telemetry', () => { afterEach(() => { setTracer(null);