From a08d0ae07c6354a222a5640db114ba3bfb43dd40 Mon Sep 17 00:00:00 2001 From: Jefferson Youashi <119521983+clintjeff2@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:20:25 +0100 Subject: [PATCH 1/2] perf(sdk/stellar): public-prefilter view-tag scan with legacy path (#45) * perf(stellar): prefilter scans with public view tags * test(stellar): cover legacy view-tag scanner --- docs/chains/stellar-view-tag-batching.md | 67 +++++++++++ src/chains/stellar/index.ts | 13 +- src/chains/stellar/scan.ts | 120 +++++++++++++++++-- src/chains/stellar/stealth.ts | 46 +++++-- test/chains/stellar/bench/scan.bench.ts | 145 +++++++++++++++++++++++ test/chains/stellar/scan.test.ts | 84 ++++++++++++- 6 files changed, 452 insertions(+), 23 deletions(-) create mode 100644 docs/chains/stellar-view-tag-batching.md create mode 100644 test/chains/stellar/bench/scan.bench.ts diff --git a/docs/chains/stellar-view-tag-batching.md b/docs/chains/stellar-view-tag-batching.md new file mode 100644 index 0000000..e814b83 --- /dev/null +++ b/docs/chains/stellar-view-tag-batching.md @@ -0,0 +1,67 @@ +# Stellar view-tag batching design + +## Problem + +The original Stellar scan path computed `S = X25519(v, R_ephemeral)` for every announcement before checking the view tag. That made the one-byte view tag a correctness filter, but not a performance filter: non-matching announcements still paid the dominant ECDH cost. + +## Chosen design + +New Stellar announcements derive the first metadata byte from public announcement data: + +```text +view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] +``` + +Where: + +- `R_ephemeral` is the 32-byte ed25519 ephemeral public key included in the announcement. +- `V_recipient` is the recipient's 32-byte ed25519 viewing public key from the meta-address. + +This keeps the stealth-address secret scalar unchanged: + +```text +S = X25519(r_ephemeral, V_recipient) = X25519(v_recipient, R_ephemeral) +hash_scalar = SHA-256("wraith:scalar:" || S) mod L +P_stealth = K_spend + hash_scalar * G +``` + +Scanners now derive `V_recipient` once from the local viewing seed, hash `R_ephemeral || V_recipient` for every announcement, and only compute X25519 plus ed25519 point addition for the roughly 1/256 announcements whose tag matches. + +## Tradeoffs + +### Benefits + +- The hot scan loop replaces nearly all X25519 operations with one SHA-256 over a small public tuple. +- The full stealth address derivation and private scalar derivation remain unchanged for matching announcements. +- The filter keeps the same one-byte false-positive rate as the previous shared-secret tag. +- Invalid 32-byte ephemeral keys are only parsed as curve points after the public tag passes; if a crafted candidate passes the tag but is not a valid point, it is skipped. + +### Costs and compatibility + +- The view tag is no longer bound to the ECDH shared secret. It is a public prefilter, not authentication. This is acceptable because the announced stealth address is still verified with the shared-secret-derived scalar before a match is returned. +- A sender that knows a recipient's public viewing key can deliberately choose metadata that passes the recipient's public prefilter. That only causes the recipient to do the same full verification they already needed for candidate announcements, and the stealth address check still prevents false matches. +- Legacy announcements whose metadata used `SHA-256("wraith:tag:" || S)[0]` are not compatible with the optimized `scanAnnouncements` path. The SDK retains `scanAnnouncementsLegacySharedSecretTag` for benchmarks and migration tooling, but using it for normal scans necessarily reintroduces one X25519 per announcement. +- If deployed contracts or indexers need to distinguish old and new metadata semantics, this should be represented as a soft fork/new scheme identifier. The SDK-side cryptographic change is isolated to metadata generation and scanning; the stealth-address math does not change. + +## Benchmarks + +The benchmark harness lives at `test/chains/stellar/bench/scan.bench.ts` and compares: + +1. `scanAnnouncementsLegacySharedSecretTag` over legacy shared-secret-tag announcements. +2. `scanAnnouncements` over new public-announcement-tag announcements. + +Run it with: + +```bash +pnpm exec vitest bench test/chains/stellar/bench/scan.bench.ts --run +``` + +The harness covers synthetic 10k, 100k, and 1M announcement datasets with one recipient match and a large pool of foreign announcements. Set `STELLAR_SCAN_BENCH_SIZES=10000` (or a comma-separated list) to run a subset locally. + +On this development container, the 10k benchmark reported: + +| Dataset | Before: shared-secret tag | After: public prefilter | Speedup | +| -------------------- | ------------------------: | ----------------------: | ------: | +| 10,000 announcements | 31,310.03 ms | 98.83 ms | 316.80x | + +The expected speedup grows with dataset size because the optimized path computes the viewing public key once and performs X25519 only for public view-tag hits instead of every same-scheme announcement. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 44b7bd3..b478622 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -1,8 +1,17 @@ export { deriveStealthKeys } from './keys'; export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, META_ADDRESS_PREFIX } from './constants'; export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address'; -export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth'; -export { checkStealthAddress, scanAnnouncements } from './scan'; +export { + generateStealthAddress, + computeSharedSecret, + computeAnnouncementViewTag, + computeViewTag, +} from './stealth'; +export { + checkStealthAddress, + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from './scan'; export { deriveStealthPrivateScalar, signStellarTransaction } from './spend'; export { seedToScalar, diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index f5bf6a1..acbf587 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -1,4 +1,5 @@ -import { computeSharedSecret, computeViewTag } from './stealth'; +import { ed25519 } from '@noble/curves/ed25519'; +import { computeAnnouncementViewTag, computeSharedSecret, computeViewTag } from './stealth'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar'; import { SCHEME_ID } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; @@ -7,12 +8,13 @@ import { hexToBytes } from './utils'; /** * Checks whether a single announcement belongs to the recipient. * - * Uses only the viewing key and spending PUBLIC key (no spending private key): - * 1. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) - * 2. View tag quick filter (eliminates ~255/256 non-matches) - * 3. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L - * 4. Expected stealth pubkey = K_spend + hash_scalar * G - * 5. Compare with announced stealth address + * Uses the cheap public view-tag prefilter before the X25519 shared secret: + * 1. Derive the viewing public key once from the viewing seed + * 2. View tag quick filter from R_ephemeral || viewing_pubkey + * 3. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) only for tag hits + * 4. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L + * 5. Expected stealth pubkey = K_spend + hash_scalar * G + * 6. Compare with announced stealth address * * This is view-only: it can detect payments but NOT derive the spending key. */ @@ -27,13 +29,51 @@ export function checkStealthAddress( hashScalar: bigint | null; stealthPubKeyBytes: Uint8Array | null; } { - const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey); + const viewingPubKey = ed25519.getPublicKey(viewingKey); + return checkStealthAddressWithViewingPubKey( + ephemeralPubKey, + viewingKey, + viewingPubKey, + spendingPubKey, + viewTag, + ); +} - const computedTag = computeViewTag(sharedSecret); +function checkStealthAddressWithViewingPubKey( + ephemeralPubKey: Uint8Array, + viewingKey: Uint8Array, + viewingPubKey: Uint8Array, + spendingPubKey: Uint8Array, + viewTag: number, +): { + isMatch: boolean; + stealthAddress: string | null; + hashScalar: bigint | null; + stealthPubKeyBytes: Uint8Array | null; +} { + const computedTag = computeAnnouncementViewTag(ephemeralPubKey, viewingPubKey); if (computedTag !== viewTag) { return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null }; } + try { + return deriveStealthAddressFromAnnouncement(ephemeralPubKey, viewingKey, spendingPubKey); + } catch { + return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null }; + } +} + +function deriveStealthAddressFromAnnouncement( + ephemeralPubKey: Uint8Array, + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, +): { + isMatch: boolean; + stealthAddress: string | null; + hashScalar: bigint | null; + stealthPubKeyBytes: Uint8Array | null; +} { + const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey); const hScalar = hashToScalar(sharedSecret); const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar); @@ -60,6 +100,7 @@ export function scanAnnouncements( spendingScalar: bigint, ): MatchedAnnouncement[] { const matched: MatchedAnnouncement[] = []; + const viewingPubKey = ed25519.getPublicKey(viewingKey); for (const ann of announcements) { if (ann.schemeId !== SCHEME_ID) continue; @@ -71,7 +112,13 @@ export function scanAnnouncements( const ephPubKey = hexToBytes(ann.ephemeralPubKey); if (ephPubKey.length !== 32) continue; - const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag); + const result = checkStealthAddressWithViewingPubKey( + ephPubKey, + viewingKey, + viewingPubKey, + spendingPubKey, + viewTag, + ); if ( result.isMatch && @@ -91,3 +138,56 @@ export function scanAnnouncements( return matched; } + +/** + * Pre-optimization scanner retained for benchmarks and migration analysis. + * + * This matches the old Stellar path: every same-scheme announcement pays for + * X25519 first, computes the legacy shared-secret tag second, and only then + * compares the announced stealth address. + */ +export function scanAnnouncementsLegacySharedSecretTag( + announcements: Announcement[], + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, + spendingScalar: bigint, +): MatchedAnnouncement[] { + const matched: MatchedAnnouncement[] = []; + + for (const ann of announcements) { + if (ann.schemeId !== SCHEME_ID) continue; + + const metadataBytes = hexToBytes(ann.metadata); + if (metadataBytes.length === 0) continue; + const viewTag = metadataBytes[0]; + + const ephPubKey = hexToBytes(ann.ephemeralPubKey); + if (ephPubKey.length !== 32) continue; + + let sharedSecret: Uint8Array; + try { + sharedSecret = computeSharedSecret(viewingKey, ephPubKey); + } catch { + continue; + } + + const computedTag = computeViewTag(sharedSecret); + if (computedTag !== viewTag) continue; + + const hScalar = hashToScalar(sharedSecret); + const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar); + const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes); + + if (stealthAddress === ann.stealthAddress) { + const stealthPrivateScalar = (spendingScalar + hScalar) % L; + + matched.push({ + ...ann, + stealthPrivateScalar, + stealthPubKeyBytes, + }); + } + } + + return matched; +} diff --git a/src/chains/stellar/stealth.ts b/src/chains/stellar/stealth.ts index 526cf1d..fb43603 100644 --- a/src/chains/stellar/stealth.ts +++ b/src/chains/stellar/stealth.ts @@ -2,8 +2,11 @@ import { ed25519 } from '@noble/curves/ed25519'; import { x25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { edwardsToMontgomeryPub, edwardsToMontgomeryPriv } from '@noble/curves/ed25519'; -import type { GeneratedStealthAddress } from './types'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './scalar'; +import type { GeneratedStealthAddress } from './types'; + +const VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); +const LEGACY_VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:tag:'); /** * Generates a one-time stealth address for a recipient on Stellar. @@ -12,7 +15,7 @@ import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './sca * 1. Generate ephemeral ed25519 keypair (r, R) * 2. ECDH: shared_secret = X25519(r, V_recipient) * 3. hash_scalar = SHA-256("wraith:scalar:" || shared_secret) mod L - * 4. view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * 4. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R || V)[0] * 5. P_stealth = K_spend + hash_scalar * G (point addition) * 6. stealth_address = Stellar encoding of P_stealth * @@ -33,7 +36,7 @@ export function generateStealthAddress( const sharedSecret = computeSharedSecret(ephSeed, viewingPubKey); - const viewTag = computeViewTag(sharedSecret); + const viewTag = computeAnnouncementViewTag(ephPubKey, viewingPubKey); const hScalar = hashToScalar(sharedSecret); @@ -59,13 +62,38 @@ export function computeSharedSecret(privateKey: Uint8Array, publicKey: Uint8Arra } /** - * Computes the view tag from a shared secret. - * view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * Computes the view tag from the public announcement tuple. + * + * view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] + * + * The tag intentionally depends only on public data already present in the + * announcement/meta-address. Scanners can reject ~255/256 announcements with + * one SHA-256 instead of paying for X25519 first; only candidates that pass + * this public prefilter need the full shared-secret derivation. + */ +export function computeAnnouncementViewTag( + ephemeralPubKey: Uint8Array, + viewingPubKey: Uint8Array, +): number { + const input = new Uint8Array( + VIEW_TAG_PREFIX.length + ephemeralPubKey.length + viewingPubKey.length, + ); + input.set(VIEW_TAG_PREFIX); + input.set(ephemeralPubKey, VIEW_TAG_PREFIX.length); + input.set(viewingPubKey, VIEW_TAG_PREFIX.length + ephemeralPubKey.length); + return sha256(input)[0]; +} + +/** + * Computes the legacy view tag from a shared secret. + * + * @deprecated Stellar scanning now uses computeAnnouncementViewTag() so the + * view-tag filter runs before X25519. This function is kept for compatibility + * checks and benchmark comparisons with the pre-batching scan path. */ export function computeViewTag(sharedSecret: Uint8Array): number { - const prefix = new TextEncoder().encode('wraith:tag:'); - const input = new Uint8Array(prefix.length + sharedSecret.length); - input.set(prefix); - input.set(sharedSecret, prefix.length); + const input = new Uint8Array(LEGACY_VIEW_TAG_PREFIX.length + sharedSecret.length); + input.set(LEGACY_VIEW_TAG_PREFIX); + input.set(sharedSecret, LEGACY_VIEW_TAG_PREFIX.length); return sha256(input)[0]; } diff --git a/test/chains/stellar/bench/scan.bench.ts b/test/chains/stellar/bench/scan.bench.ts new file mode 100644 index 0000000..c5d64ec --- /dev/null +++ b/test/chains/stellar/bench/scan.bench.ts @@ -0,0 +1,145 @@ +import { bench, describe, expect, test } from 'vitest'; +import { deriveStealthKeys } from '../../../../src/chains/stellar/keys'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../../src/chains/stellar/stealth'; +import { + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from '../../../../src/chains/stellar/scan'; +import { SCHEME_ID } from '../../../../src/chains/stellar/constants'; +import { bytesToHex } from '../../../../src/chains/stellar/utils'; +import type { Announcement, StealthKeys } from '../../../../src/chains/stellar/types'; + +const MATCH_INDEX = 997; +const POOL_SIZE = 512; +const DEFAULT_DATASET_SIZES = [10_000, 100_000, 1_000_000] as const; +const DATASET_SIZES = ( + process.env.STELLAR_SCAN_BENCH_SIZES?.split(',').map(Number) ?? [...DEFAULT_DATASET_SIZES] +).filter((size) => Number.isFinite(size) && size > 0); +const BENCH_OPTIONS = { time: 1, iterations: 1, warmupTime: 0, warmupIterations: 0 }; + +const keys = deriveStealthKeys(new Uint8Array(64).fill(0xaa)); +const foreignKeys = deriveStealthKeys(new Uint8Array(64).fill(0xbb)); + +function seedFor(index: number): Uint8Array { + const seed = new Uint8Array(32); + let state = (index + 1) * 0x9e3779b1; + for (let i = 0; i < seed.length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + seed[i] = state & 0xff; + } + return seed; +} + +function makeAnnouncementFor( + recipient: StealthKeys, + ephemeralSeed: Uint8Array, + tagScheme: 'legacy-shared-secret' | 'public-announcement', +): Announcement { + const stealth = generateStealthAddress( + recipient.spendingPubKey, + recipient.viewingPubKey, + ephemeralSeed, + ); + const sharedSecret = computeSharedSecret(ephemeralSeed, recipient.viewingPubKey); + const viewTag = + tagScheme === 'legacy-shared-secret' + ? computeViewTag(sharedSecret) + : computeAnnouncementViewTag(stealth.ephemeralPubKey, recipient.viewingPubKey); + + return { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: viewTag.toString(16).padStart(2, '0'), + }; +} + +const pools = { + legacy: Array.from({ length: POOL_SIZE }, (_, i) => + makeAnnouncementFor(foreignKeys, seedFor(i), 'legacy-shared-secret'), + ), + optimized: Array.from({ length: POOL_SIZE }, (_, i) => + makeAnnouncementFor(foreignKeys, seedFor(i), 'public-announcement'), + ), +}; + +const matchingAnnouncements = { + legacy: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'legacy-shared-secret'), + optimized: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'public-announcement'), +}; + +function makeDataset(size: number, tagScheme: 'legacy' | 'optimized') { + const foreignPool = pools[tagScheme]; + const matchingAnnouncement = matchingAnnouncements[tagScheme]; + + return Array.from({ length: size }, (_, i) => + i === MATCH_INDEX ? matchingAnnouncement : foreignPool[i % foreignPool.length], + ); +} + +const datasets = new Map( + DATASET_SIZES.map((size) => [ + size, + { + legacy: makeDataset(size, 'legacy'), + optimized: makeDataset(size, 'optimized'), + }, + ]), +); + +describe('Stellar scan benchmark fixtures', () => { + test('optimized scanner preserves correctness on the 10k synthetic dataset', () => { + const dataset = datasets.get(10_000)?.optimized; + expect(dataset).toBeDefined(); + + const matched = scanAnnouncements( + dataset!, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(1); + expect(matched[0].stealthAddress).toBe(matchingAnnouncements.optimized.stealthAddress); + }); +}); + +describe('Stellar scan announcement view-tag batching', () => { + for (const size of DATASET_SIZES) { + const dataset = datasets.get(size)!; + + bench( + `before: shared-secret view tag (${size.toLocaleString()} announcements)`, + () => { + scanAnnouncementsLegacySharedSecretTag( + dataset.legacy, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + }, + BENCH_OPTIONS, + ); + + bench( + `after: public view-tag prefilter (${size.toLocaleString()} announcements)`, + () => { + scanAnnouncements( + dataset.optimized, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + }, + BENCH_OPTIONS, + ); + } +}); diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index 4cbbc5b..b801cce 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -1,7 +1,16 @@ import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; -import { generateStealthAddress } from '../../../src/chains/stellar/stealth'; -import { checkStealthAddress, scanAnnouncements } from '../../../src/chains/stellar/scan'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../src/chains/stellar/stealth'; +import { + checkStealthAddress, + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from '../../../src/chains/stellar/scan'; import { SCHEME_ID } from '../../../src/chains/stellar/constants'; import { bytesToHex } from '../../../src/chains/stellar/utils'; import type { Announcement } from '../../../src/chains/stellar/types'; @@ -110,6 +119,77 @@ describe('scanAnnouncements', () => { expect(matched).toHaveLength(0); }); + test('skips invalid ephemeral keys even when the public view tag matches', () => { + const keys = deriveStealthKeys(testSig); + const invalidEphemeralPubKey = new Uint8Array(32); + const matchingPublicTag = computeAnnouncementViewTag( + invalidEphemeralPubKey, + keys.viewingPubKey, + ); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(invalidEphemeralPubKey), + metadata: matchingPublicTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(0); + }); + + test('keeps legacy shared-secret view tags on the legacy scanner path', () => { + const keys = deriveStealthKeys(testSig); + let ephemeralSeed = new Uint8Array(32).fill(0x11); + let stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, ephemeralSeed); + let sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + let legacyTag = computeViewTag(sharedSecret); + + // Use a deterministic seed whose legacy shared-secret tag differs from the + // optimized public-announcement tag so the migration boundary is explicit. + for (let i = 0; legacyTag === stealth.viewTag && i < 255; i++) { + ephemeralSeed = new Uint8Array(32).fill(0x12 + i); + stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, ephemeralSeed); + sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + legacyTag = computeViewTag(sharedSecret); + } + + expect(legacyTag).not.toBe(stealth.viewTag); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: legacyTag.toString(16).padStart(2, '0'), + }, + ]; + + expect( + scanAnnouncements(announcements, keys.viewingKey, keys.spendingPubKey, keys.spendingScalar), + ).toHaveLength(0); + + const legacyMatched = scanAnnouncementsLegacySharedSecretTag( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(legacyMatched).toHaveLength(1); + expect(legacyMatched[0].stealthAddress).toBe(stealth.stealthAddress); + }); + test('filters mix of own and foreign announcements', () => { const keys = deriveStealthKeys(testSig); const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); From 99889cea9335992d0264604fc48a10329bafd66b Mon Sep 17 00:00:00 2001 From: fadesany Date: Tue, 23 Jun 2026 14:30:26 +0000 Subject: [PATCH 2/2] feat(stellar): add StellarBatchBuilder for multi-stealth-payment transactions StellarBatchBuilder accepts multiple stealth payment configs and builds Stellar transactions with payment + manageData announcement pairs. - Groups payments into chunks of 50 (max 100 ops per tx) - Validates total fee against account balance - Splits into multiple transactions if op count > 100 - Encodes announcement data as 34-byte manageData values - Full validation for addresses, ephemeral keys, view tags, amounts --- src/chains/stellar/batch.ts | 328 ++++++++++++++++++++++++++ src/chains/stellar/index.ts | 2 + test/chains/stellar/batch.test.ts | 370 ++++++++++++++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 src/chains/stellar/batch.ts create mode 100644 test/chains/stellar/batch.test.ts diff --git a/src/chains/stellar/batch.ts b/src/chains/stellar/batch.ts new file mode 100644 index 0000000..56d8da4 --- /dev/null +++ b/src/chains/stellar/batch.ts @@ -0,0 +1,328 @@ +import { + TransactionBuilder, + Account, + Operation, + BASE_FEE, + Asset, + StrKey, +} from '@stellar/stellar-sdk'; +import { hexToBytes } from './utils'; +import { SCHEME_ID } from './constants'; + +const MAX_OPS_PER_TX = 100; +const OPS_PER_PAYMENT = 2; +const MAX_PAYMENTS_PER_TX = Math.floor(MAX_OPS_PER_TX / OPS_PER_PAYMENT); + +export interface StealthPaymentConfig { + /** Stellar public key (G...) of the stealth address. */ + destination: string; + /** Amount to send (e.g. "10.5"). */ + amount: string; + /** Asset code ("native", "XLM", or a custom asset code). Defaults to "native". */ + asset?: string; + /** Asset issuer G... address (required for non-native assets). */ + assetIssuer?: string; + /** Hex-encoded 32-byte ephemeral public key. */ + ephemeralPubKey: string; + /** View tag byte (0–255). */ + viewTag: number; + /** Optional caller — defaults to the source account. */ + caller?: string; +} + +export interface BatchConfig { + /** Source account public key (G...). */ + source: string; + /** Current sequence number of the source account. */ + sequence: string; + /** Stellar network passphrase (e.g. Networks.TESTNET). */ + networkPassphrase: string; + /** Fee per operation in stroops. Defaults to BASE_FEE (100). */ + feePerOp?: number; + /** Timeout in seconds. Defaults to 300. */ + timeout?: number; +} + +export interface BuildResult { + /** Transaction envelope XDR (base64) strings, one per transaction. */ + transactions: string[]; + /** Number of transactions built. */ + txCount: number; + /** Total number of stealth payments across all transactions. */ + paymentCount: number; + /** Total fee across all transactions in stroops. */ + totalFee: number; +} + +/** + * Encodes announcement data into a compact binary format suitable for + * storing in a Stellar manageData operation value (max 64 bytes). + * + * Format (34 bytes): + * [0] — schemeId (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.set(ephemeralPubKey, 1); + data[33] = viewTag; + return data; +} + +/** + * Decodes announcement data back into its components. + */ +export function decodeAnnouncementData(data: Uint8Array): { + schemeId: number; + ephemeralPubKey: Uint8Array; + viewTag: number; +} { + if (data.length < 34) { + throw new Error(`Invalid announcement data length: expected 34 bytes, got ${data.length}`); + } + const ephPubKey = new Uint8Array(data.slice(1, 33)); + const viewTag = data[33]; + return { + schemeId: data[0], + ephemeralPubKey: ephPubKey, + viewTag, + }; +} + +/** + * Builds Stellar transactions for multiple stealth payments in a batch. + * + * Stellar supports up to 100 operations per transaction with atomic semantics, + * so a batch of N stealth payments produces ceil(N / 50) transactions, each + * containing up to 50 payment+announcement pairs (100 ops). + * + * Usage: + * ```ts + * const builder = new StellarBatchBuilder({ + * source: 'G...', + * sequence: '1234', + * networkPassphrase: Networks.TESTNET, + * }); + * + * builder + * .addPayment({ + * destination: 'G...', + * amount: '10', + * asset: 'native', + * ephemeralPubKey: 'aabb...', + * viewTag: 42, + * }) + * .addPayment({ ... }); + * + * const result = builder.build(); + * // result.transactions — XDR base64 strings ready to sign + * ``` + */ +export class StellarBatchBuilder { + private config: BatchConfig; + private payments: StealthPaymentConfig[] = []; + private _needsBuild = true; + private _cachedResult: BuildResult | null = null; + + constructor(config: BatchConfig) { + this.config = { + feePerOp: Number(BASE_FEE), + timeout: 300, + ...config, + }; + this.validateAddress(config.source, 'source'); + } + + /** + * Validates a Stellar G... address. + */ + private validateAddress(addr: string, label: string): void { + if (!StrKey.isValidEd25519PublicKey(addr)) { + throw new Error(`Invalid ${label} address: ${addr}`); + } + } + + /** + * Validates a single payment config. + */ + private validatePayment(payment: StealthPaymentConfig, index: number): void { + this.validateAddress(payment.destination, `payment[${index}].destination`); + + const ephBytes = hexToBytes(payment.ephemeralPubKey); + if (ephBytes.length !== 32) { + throw new Error( + `payment[${index}].ephemeralPubKey: expected 32 bytes (64 hex chars), got ${ephBytes.length} bytes`, + ); + } + + if (payment.viewTag < 0 || payment.viewTag > 255) { + throw new Error(`payment[${index}].viewTag: expected 0–255, got ${payment.viewTag}`); + } + + const amount = Number(payment.amount); + if (!isFinite(amount) || amount <= 0) { + throw new Error( + `payment[${index}].amount: expected positive number, got "${payment.amount}"`, + ); + } + + if (payment.asset && payment.asset !== 'native' && payment.asset !== 'XLM') { + if (!payment.assetIssuer) { + throw new Error(`payment[${index}].assetIssuer is required for asset "${payment.asset}"`); + } + this.validateAddress(payment.assetIssuer, `payment[${index}].assetIssuer`); + } + } + + /** + * Adds a stealth payment to the batch. + * + * Each payment produces two operations: + * - A payment operation sending assets to the stealth address. + * - A manageData operation storing the announcement data. + */ + addPayment(payment: StealthPaymentConfig): this { + this.validatePayment(payment, this.payments.length); + this.payments.push(payment); + this._needsBuild = true; + return this; + } + + /** + * Returns the number of payments currently in the batch. + */ + get paymentCount(): number { + return this.payments.length; + } + + /** + * Returns the number of operations the batch would produce + * (payments * 2) before splitting. + */ + get operationCount(): number { + return this.payments.length * OPS_PER_PAYMENT; + } + + /** + * Returns the number of transactions the batch will split into. + */ + get expectedTransactionCount(): number { + if (this.payments.length === 0) return 0; + return Math.ceil(this.payments.length / MAX_PAYMENTS_PER_TX); + } + + /** + * Validates that the source account balance can cover the total fee. + * + * @param balanceXlm Account XLM balance as a string (e.g. "100.5"). + * @throws If the total fee (converted to XLM) exceeds the balance. + */ + validateBalance(balanceXlm: string): void { + const feePerOp = this.config.feePerOp!; + const totalOps = this.operationCount; + const totalFeeStroops = totalOps * feePerOp; + const totalFeeXlm = totalFeeStroops / 10_000_000; + + const balance = Number(balanceXlm); + if (!isFinite(balance) || balance < 0) { + throw new Error(`Invalid balance: "${balanceXlm}"`); + } + + if (totalFeeXlm > balance) { + throw new Error( + `Insufficient balance: ${totalFeeXlm} XLM fee required but account has ${balanceXlm} XLM`, + ); + } + } + + /** + * Builds the transaction(s) for all added payments. + * + * Groups payments into chunks of at most `MAX_PAYMENTS_PER_TX` (50), + * producing one Stellar transaction per chunk. Each transaction includes + * the payment operations followed by announcement manageData operations + * for every recipient in the chunk. + * + * @returns BuildResult containing the XDR base64-encoded transactions. + */ + build(): BuildResult { + if (!this._needsBuild && this._cachedResult) { + return this._cachedResult; + } + + if (this.payments.length === 0) { + throw new Error('No payments added to the batch'); + } + + const feePerOp = this.config.feePerOp!; + const timeout = this.config.timeout!; + const sourceStr = this.config.source; + const chunks: StealthPaymentConfig[][] = []; + + for (let i = 0; i < this.payments.length; i += MAX_PAYMENTS_PER_TX) { + chunks.push(this.payments.slice(i, i + MAX_PAYMENTS_PER_TX)); + } + + const transactions: string[] = []; + let seqNum = BigInt(this.config.sequence); + + for (const chunk of chunks) { + const sourceAccount = new Account(sourceStr, seqNum.toString()); + const opsCount = chunk.length * OPS_PER_PAYMENT; + const fee = String(opsCount * feePerOp); + + const builder = new TransactionBuilder(sourceAccount, { + fee, + networkPassphrase: this.config.networkPassphrase, + }); + + for (let i = 0; i < chunk.length; i++) { + const payment = chunk[i]; + + const asset = + payment.asset && payment.asset !== 'native' && payment.asset !== 'XLM' + ? new Asset(payment.asset, payment.assetIssuer!) + : Asset.native(); + + builder.addOperation( + Operation.payment({ + destination: payment.destination, + asset, + amount: payment.amount, + }), + ); + + const ephBytes = hexToBytes(payment.ephemeralPubKey); + const annData = encodeAnnouncementData(ephBytes, payment.viewTag); + + builder.addOperation( + Operation.manageData({ + name: `wraith:ann:${i}`, + value: Buffer.from(annData), + }), + ); + } + + builder.setTimeout(timeout); + const tx = builder.build(); + transactions.push(tx.toEnvelope().toXDR('base64')); + + seqNum++; + } + + const totalOps = this.payments.length * OPS_PER_PAYMENT; + const totalFee = totalOps * feePerOp; + + this._cachedResult = { + transactions, + txCount: transactions.length, + paymentCount: this.payments.length, + totalFee, + }; + this._needsBuild = false; + + return this._cachedResult; + } +} diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index b478622..88d0363 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -24,6 +24,8 @@ export { export { bytesToHex, hexToBytes } from './utils'; export { fetchAnnouncements } from './announcements'; export { DEPLOYMENTS, getDeployment } from './deployments'; +export { StellarBatchBuilder, encodeAnnouncementData, decodeAnnouncementData } from './batch'; +export type { StealthPaymentConfig, BatchConfig, BuildResult } from './batch'; export type { StellarChainDeployment } from './deployments'; export type { HexString, diff --git a/test/chains/stellar/batch.test.ts b/test/chains/stellar/batch.test.ts new file mode 100644 index 0000000..12f382e --- /dev/null +++ b/test/chains/stellar/batch.test.ts @@ -0,0 +1,370 @@ +import { describe, test, expect } from 'vitest'; +import { Networks } from '@stellar/stellar-sdk'; +import { + StellarBatchBuilder, + encodeAnnouncementData, + decodeAnnouncementData, +} from '../../../src/chains/stellar/batch'; +import type { StealthPaymentConfig, BatchConfig } from '../../../src/chains/stellar/batch'; +import { SCHEME_ID } from '../../../src/chains/stellar/constants'; + +const SOURCE = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const DEST = 'GCVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVH7N'; +const DEST2 = 'GC53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XUGE'; +const EPH_PUB_KEY = 'aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd'; +const VIEW_TAG = 42; + +function makePayment(overrides?: Partial): StealthPaymentConfig { + return { + destination: DEST, + amount: '10', + asset: 'native', + ephemeralPubKey: EPH_PUB_KEY, + viewTag: VIEW_TAG, + ...overrides, + }; +} + +function makeConfig(overrides?: Partial): BatchConfig { + return { + source: SOURCE, + sequence: '1234', + networkPassphrase: Networks.TESTNET, + ...overrides, + }; +} + +const ephBytes = Buffer.from(EPH_PUB_KEY, 'hex'); + +describe('encodeAnnouncementData / decodeAnnouncementData', () => { + test('roundtrip', () => { + const encoded = encodeAnnouncementData(ephBytes, VIEW_TAG); + expect(encoded.length).toBe(34); + expect(encoded[0]).toBe(SCHEME_ID); + + const decoded = decodeAnnouncementData(encoded); + expect(decoded.schemeId).toBe(SCHEME_ID); + expect(Buffer.from(decoded.ephemeralPubKey).toString('hex')).toBe(EPH_PUB_KEY); + expect(decoded.viewTag).toBe(VIEW_TAG); + }); + + test('roundtrip with different values', () => { + const eph = '1122334411223344112233441122334411223344112233441122334411223344'; + const tag = 200; + const ephBytes2 = Buffer.from(eph, 'hex'); + const encoded = encodeAnnouncementData(ephBytes2, tag); + expect(encoded.length).toBe(34); + + const decoded = decodeAnnouncementData(encoded); + expect(decoded.schemeId).toBe(SCHEME_ID); + expect(Buffer.from(decoded.ephemeralPubKey).toString('hex')).toBe(eph); + expect(decoded.viewTag).toBe(tag); + }); + + test('throws on short data', () => { + expect(() => decodeAnnouncementData(new Uint8Array(10))).toThrow( + 'Invalid announcement data length', + ); + expect(() => decodeAnnouncementData(new Uint8Array(33))).toThrow( + 'Invalid announcement data length', + ); + }); +}); + +describe('StellarBatchBuilder', () => { + test('builds a single payment transaction', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + const result = builder.build(); + + expect(result.transactions).toHaveLength(1); + expect(result.txCount).toBe(1); + expect(result.paymentCount).toBe(1); + expect(result.totalFee).toBe(200); // 2 ops * 100 stroops + expect(typeof result.transactions[0]).toBe('string'); + expect(result.transactions[0].length).toBeGreaterThan(100); + }); + + test('builds multiple payments in one transaction', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment({ destination: DEST })); + builder.addPayment(makePayment({ destination: DEST2 })); + const result = builder.build(); + + expect(result.transactions).toHaveLength(1); + expect(result.paymentCount).toBe(2); + expect(result.totalFee).toBe(400); // 4 ops * 100 stroops + }); + + test('splits into multiple transactions when exceeding 50 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 60; i++) { + builder.addPayment(makePayment({ destination: DEST })); + } + expect(builder.expectedTransactionCount).toBe(2); + + const result = builder.build(); + expect(result.transactions).toHaveLength(2); + expect(result.paymentCount).toBe(60); + + // First tx: 50 payments * 2 ops = 100 ops, second: 10 payments * 2 = 20 ops + const totalFee = 60 * 2 * 100; + expect(result.totalFee).toBe(totalFee); + + // Verify splitting boundary + expect(builder.paymentCount).toBe(60); + expect(builder.operationCount).toBe(120); + }); + + test('splits exactly at 51 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 51; i++) { + builder.addPayment(makePayment()); + } + expect(builder.expectedTransactionCount).toBe(2); + const result = builder.build(); + expect(result.transactions).toHaveLength(2); + expect(result.paymentCount).toBe(51); + }); + + test('single tx at exactly 50 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 50; i++) { + builder.addPayment(makePayment()); + } + expect(builder.expectedTransactionCount).toBe(1); + const result = builder.build(); + expect(result.transactions).toHaveLength(1); + expect(result.paymentCount).toBe(50); + }); + + test('splits at 101 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 101; i++) { + builder.addPayment(makePayment()); + } + expect(builder.expectedTransactionCount).toBe(3); + const result = builder.build(); + expect(result.transactions).toHaveLength(3); + expect(result.paymentCount).toBe(101); + }); + + test('caches build result', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + const r1 = builder.build(); + const r2 = builder.build(); + expect(r1).toBe(r2); + }); + + test('invalidates cache after addPayment', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + const r1 = builder.build(); + builder.addPayment(makePayment({ destination: DEST2 })); + const r2 = builder.build(); + expect(r1.paymentCount).toBe(1); + expect(r2.paymentCount).toBe(2); + expect(r1.transactions).not.toEqual(r2.transactions); + }); + + test('throws on empty batch', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.build()).toThrow('No payments added'); + }); + + test('expectedTransactionCount is 0 for empty builder', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(builder.expectedTransactionCount).toBe(0); + expect(builder.paymentCount).toBe(0); + expect(builder.operationCount).toBe(0); + }); + + test('paymentCount and operationCount reflect added payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(builder.paymentCount).toBe(0); + expect(builder.operationCount).toBe(0); + builder.addPayment(makePayment()); + expect(builder.paymentCount).toBe(1); + expect(builder.operationCount).toBe(2); + builder.addPayment(makePayment()); + expect(builder.paymentCount).toBe(2); + expect(builder.operationCount).toBe(4); + }); +}); + +describe('validation', () => { + test('rejects invalid source address', () => { + expect(() => new StellarBatchBuilder(makeConfig({ source: 'invalid' }))).toThrow( + 'Invalid source address', + ); + }); + + test('rejects invalid destination address', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ destination: 'invalid' }))).toThrow( + 'destination', + ); + }); + + test('rejects invalid ephemeral pub key (wrong length)', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ ephemeralPubKey: 'aabb' }))).toThrow( + 'expected 32 bytes', + ); + }); + + test('rejects invalid view tag (>255)', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ viewTag: 300 }))).toThrow( + 'viewTag: expected 0–255', + ); + }); + + test('rejects invalid view tag (<0)', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ viewTag: -1 }))).toThrow( + 'viewTag: expected 0–255', + ); + }); + + test('rejects negative amount', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ amount: '-10' }))).toThrow( + 'expected positive number', + ); + }); + + test('rejects zero amount', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ amount: '0' }))).toThrow( + 'expected positive number', + ); + }); + + test('rejects non-numeric amount', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ amount: 'abc' }))).toThrow( + 'expected positive number', + ); + }); + + test('rejects custom asset without issuer', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ asset: 'USDC' }))).toThrow( + 'assetIssuer is required', + ); + }); + + test('accepts custom asset with issuer', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => + builder.addPayment( + makePayment({ + asset: 'USDC', + assetIssuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }), + ), + ).not.toThrow(); + }); + + test('rejects custom asset with invalid issuer', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => + builder.addPayment( + makePayment({ + asset: 'USDC', + assetIssuer: 'invalid', + }), + ), + ).toThrow('assetIssuer'); + }); + + test('accepts XLM as asset alias', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ asset: 'XLM' }))).not.toThrow(); + }); +}); + +describe('validateBalance', () => { + test('passes with sufficient balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('1')).not.toThrow(); + }); + + test('passes with exact balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + // 2 ops * 100 stroops = 200 stroops = 0.00002 XLM + expect(() => builder.validateBalance('0.00002')).not.toThrow(); + }); + + test('throws with insufficient balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('0.00001')).toThrow('Insufficient balance'); + }); + + test('throws with invalid balance string', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('abc')).toThrow('Invalid balance'); + }); + + test('throws with negative balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('-1')).toThrow('Invalid balance'); + }); + + test('accounts for multiple payments correctly', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + builder.addPayment(makePayment()); + // 4 ops * 100 = 400 stroops = 0.00004 XLM + expect(() => builder.validateBalance('0.00004')).not.toThrow(); + expect(() => builder.validateBalance('0.00003')).toThrow('Insufficient balance'); + }); +}); + +describe('custom fee per op', () => { + test('uses custom fee per operation', () => { + const builder = new StellarBatchBuilder(makeConfig({ feePerOp: 200 })); + builder.addPayment(makePayment()); + builder.addPayment(makePayment()); + const result = builder.build(); + // 4 ops * 200 = 800 stroops + expect(result.totalFee).toBe(800); + }); + + test('validateBalance accounts for custom fee', () => { + const builder = new StellarBatchBuilder(makeConfig({ feePerOp: 1000 })); + builder.addPayment(makePayment()); + // 2 ops * 1000 = 2000 stroops = 0.0002 XLM + expect(() => builder.validateBalance('0.0002')).not.toThrow(); + expect(() => builder.validateBalance('0.00019')).toThrow('Insufficient balance'); + }); +}); + +describe('chained addPayment calls', () => { + test('supports method chaining', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()).addPayment(makePayment()).addPayment(makePayment()); + expect(builder.paymentCount).toBe(3); + }); +}); + +describe('non-native asset in transaction', () => { + test('builds transaction with custom asset', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment( + makePayment({ + asset: 'USDC', + assetIssuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }), + ); + const result = builder.build(); + expect(result.transactions).toHaveLength(1); + }); +});