From d0896d12d3bcc900fb4f660f25165703778b908a Mon Sep 17 00:00:00 2001 From: Leke Ernest Date: Tue, 28 Jul 2026 16:22:34 +0000 Subject: [PATCH 1/5] feat: add invoice expiry guard with transaction timebounds enforcement Extends StellarSplitTxBuilder.build() and .submit() to accept an optional expiresAt timestamp. When provided, translates the invoice expiry into transaction timebounds via TransactionBuilder.setTimeout(), causing the ledger to reject submissions after the deadline has passed. Adds checkInvoiceExpiry() pre-submission check to preflightChecker.ts and PaymentExpiredError to errors.ts. --- src/txBuilder.ts | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/txBuilder.ts b/src/txBuilder.ts index d5a5db1..f070e95 100644 --- a/src/txBuilder.ts +++ b/src/txBuilder.ts @@ -11,6 +11,7 @@ import { import type { StellarSplitClientConfig } from "./client.js"; import { signTransaction } from "./wallet.js"; import { SimulationFailedError, TransactionFailedError, TransactionNotConfirmedError } from "./errors.js"; +import { checkInvoiceExpiry } from "./preflightChecker.js"; /** Builder for composing multi-operation StellarSplit transactions. */ export class StellarSplitTxBuilder { @@ -75,8 +76,14 @@ export class StellarSplitTxBuilder { /** * Build an unsigned Transaction using a fallback source account (sequence 0). * This is synchronous and suitable for offline signing or inspection. + * + * @param options - Optional invoice expiry info. + * @param options.expiresAt - Unix timestamp (seconds) for invoice expiry. + * @param options.invoiceId - Invoice ID for accurate error reporting. */ - build(): Transaction { + build(options?: { expiresAt?: number; invoiceId?: string }): Transaction { + const expiresAt = options?.expiresAt; + const invoiceId = options?.invoiceId ?? "unknown"; const sourceAccount = ({ accountId: () => this.sourceAddress, sequenceNumber: () => "0", @@ -92,14 +99,34 @@ export class StellarSplitTxBuilder { tb.addOperation(op); } - tb.setTimeout(30); + if (expiresAt !== undefined) { + // Enforce invoice expiry at the ledger level via timebounds. + const expiry = checkInvoiceExpiry(expiresAt, invoiceId); + const timeoutSeconds = Math.max(1, expiry.secondsRemaining); + // Cap at 12 hours (43200s) to avoid unreasonable timebounds + tb.setTimeout(Math.min(timeoutSeconds, 43200)); + } else { + tb.setTimeout(30); + } return tb.build(); } /** * Sign and submit the composed transaction. Returns transaction hash when confirmed. + * + * @param options - Optional invoice expiry info. + * @param options.expiresAt - Unix timestamp (seconds) for invoice expiry. + * @param options.invoiceId - Invoice ID for accurate error reporting. */ - async submit(): Promise<{ txHash: string }> { + async submit(options?: { expiresAt?: number; invoiceId?: string }): Promise<{ txHash: string }> { + const expiresAt = options?.expiresAt; + const invoiceId = options?.invoiceId ?? "unknown"; + + if (expiresAt !== undefined) { + // Fail fast before fetching account / building tx + checkInvoiceExpiry(expiresAt, invoiceId); + } + const account = await this.server.getAccount(this.sourceAddress); const tb = new TransactionBuilder(account, { @@ -108,7 +135,14 @@ export class StellarSplitTxBuilder { }); for (const op of this.operations) tb.addOperation(op); - tb.setTimeout(30); + + if (expiresAt !== undefined) { + const nowSeconds = Math.floor(Date.now() / 1000); + const timeoutSeconds = Math.max(1, expiresAt - nowSeconds); + tb.setTimeout(Math.min(timeoutSeconds, 43200)); + } else { + tb.setTimeout(30); + } const tx = tb.build(); const simResult = await this.server.simulateTransaction(tx); From 65a95a31a904432f6fd78c01dd163aef2dc14efa Mon Sep 17 00:00:00 2001 From: Leke Ernest Date: Tue, 28 Jul 2026 16:22:34 +0000 Subject: [PATCH 2/5] feat: add sponsorship reserve verifier for pre-submission XLM balance check Adds checkSponsorReserve() to verify sponsoring accounts have sufficient XLM reserve before building sponsored-reserve onboarding transactions. Queries Horizon loadAccount() for subentry_count and num_sponsored, computes required reserve against BASE_RESERVE, and throws InsufficientSponsorReserveError on shortfall. Adds InsufficientSponsorReserveError to errors.ts and SponsorshipConfig / SponsorReserveCheckResult to types.ts. Exposes checkSponsorshipReserve() convenience wrapper in sponsorship.ts. --- src/errors.ts | 76 ++++++++++++++++++++ src/preflightChecker.ts | 153 +++++++++++++++++++++++++++++++++++++++- src/sponsorship.ts | 26 +++++++ src/types.ts | 127 +++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 32cca1c..4739717 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1459,3 +1459,79 @@ export class PassphraseMismatchError extends StellarSplitError { export function isIPFSConfigError(err: unknown): err is IPFSConfigError { return err instanceof IPFSConfigError; } + +// --------------------------------------------------------------------------- +// Sponsorship Reserve Verifier Error +// --------------------------------------------------------------------------- + +/** + * Thrown when the sponsoring account does not have enough XLM reserve + * to cover the new ledger entries it will sponsor. + */ +export class InsufficientSponsorReserveError extends StellarSplitError { + readonly sponsorAddress: string; + readonly availableStroops: bigint; + readonly requiredStroops: bigint; + readonly newEntries: number; + + constructor( + sponsorAddress: string, + available: bigint, + required: bigint, + newEntries: number, + raw?: string, + ) { + super( + `Sponsor ${sponsorAddress} has insufficient XLM reserve: ` + + `${available} stroops available, ${required} stroops required ` + + `(${newEntries} new sponsored entries)`, + "INSUFFICIENT_SPONSOR_RESERVE", + { sponsorAddress, available: available.toString(), required: required.toString(), newEntries }, + raw, + ); + this.name = "InsufficientSponsorReserveError"; + this.sponsorAddress = sponsorAddress; + this.availableStroops = available; + this.requiredStroops = required; + this.newEntries = newEntries; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInsufficientSponsorReserveError( + err: unknown, +): err is InsufficientSponsorReserveError { + return err instanceof InsufficientSponsorReserveError; +} + +// --------------------------------------------------------------------------- +// Payment Expired Error (Invoice Expiry Guard) +// --------------------------------------------------------------------------- + +/** + * Thrown when a payment is submitted after the invoice has expired. + */ +export class PaymentExpiredError extends StellarSplitError { + readonly invoiceId: string; + readonly expiresAt: number; + readonly now: number; + + constructor(invoiceId: string, expiresAt: number, raw?: string) { + const now = Math.floor(Date.now() / 1000); + super( + `Payment rejected: invoice ${invoiceId} expired at ${expiresAt} (now: ${now})`, + "PAYMENT_EXPIRED", + { invoiceId, expiresAt, now }, + raw, + ); + this.name = "PaymentExpiredError"; + this.invoiceId = invoiceId; + this.expiresAt = expiresAt; + this.now = now; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPaymentExpiredError(err: unknown): err is PaymentExpiredError { + return err instanceof PaymentExpiredError; +} diff --git a/src/preflightChecker.ts b/src/preflightChecker.ts index 675fadd..1a6c9e1 100644 --- a/src/preflightChecker.ts +++ b/src/preflightChecker.ts @@ -1,4 +1,4 @@ -import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk"; export type PayerReadinessReason = | "account_not_found" @@ -10,6 +10,157 @@ export interface PayerReadinessResult { reason?: PayerReadinessReason; } +// --------------------------------------------------------------------------- +// Invoice Expiry Check +// --------------------------------------------------------------------------- + +/** Reason an invoice expiry check failed. */ +export type InvoiceExpiryReason = "expired" | "expires_within_timebounds"; + +/** Result of the invoice expiry preflight check. */ +export interface InvoiceExpiryResult { + /** Whether the invoice is still valid for payments. */ + valid: boolean; + /** Reason for invalidity, if any. */ + reason?: InvoiceExpiryReason; + /** Unix timestamp (seconds) when the invoice expires. */ + expiresAt: number; + /** Remaining seconds until expiry (0 or negative if expired). */ + secondsRemaining: number; +} + +import { PaymentExpiredError } from "./errors.js"; + +/** + * Check whether an invoice has expired and should reject a payment submission. + * + * When the invoice has expired, throws {@link PaymentExpiredError}. + * When it is still valid, returns the remaining time in seconds — ready to + * be passed to {@link TransactionBuilder.setTimeout} as the transaction + * `timeBounds.maxTime`. + * + * @param expiresAt - Unix timestamp (seconds) when the invoice expires. + * @param invoiceId - The invoice ID for error messaging. + * @returns Expiry result with remaining seconds for timebounds. + * + * @throws {PaymentExpiredError} When the invoice has already expired. + */ +export function checkInvoiceExpiry( + expiresAt: number, + invoiceId: string, +): InvoiceExpiryResult { + const nowSeconds = Math.floor(Date.now() / 1000); + const secondsRemaining = expiresAt - nowSeconds; + const expired = secondsRemaining <= 0; + + if (expired) { + throw new PaymentExpiredError(invoiceId, expiresAt); + } + + // If the remaining time is very short (< 30s), warn but still allow + if (secondsRemaining < 30) { + return { + valid: true, + reason: "expires_within_timebounds", + expiresAt, + secondsRemaining, + }; + } + + return { + valid: true, + expiresAt, + secondsRemaining, + }; +} + +// --------------------------------------------------------------------------- +// Sponsor Reserve Preflight Check +// --------------------------------------------------------------------------- + +/** Minimum XLM balance an account must retain (2 × BASE_RESERVE = 1 XLM). */ +const SPONSOR_MIN_BALANCE_STROOPS = 10_000_000n; + +/** One base reserve in stroops (0.5 XLM). */ +const BASE_RESERVE_STROOPS = 5_000_000n; + +/** + * Convert a Horizon balance string ("1.0000000") to stroops (bigint). + */ +function xlmStringToStroops(xlm: string): bigint { + const [whole = "0", frac = ""] = xlm.split("."); + return BigInt(whole) * 10_000_000n + BigInt(frac.padEnd(7, "0").slice(0, 7)); +} + +/** Result of a sponsor reserve check. */ +export interface SponsorReserveCheck { + sufficient: boolean; + availableStroops: bigint; + requiredStroops: bigint; + shortfallStroops: bigint; +} + +import { InsufficientSponsorReserveError } from "./errors.js"; + +/** + * Pre-submission check: verify the sponsoring account has enough XLM reserve + * to cover the new ledger entries it will sponsor. + * + * @param sponsorAddress - Stellar address of the sponsoring account. + * @param newEntryCount - Number of new ledger entries the sponsor will cover. + * @param horizonUrl - Horizon API base URL for account lookups. + * @param throwOnInsufficient - When true (default), throws on insufficient reserve. + * + * @returns Reserve check result. + * @throws {InsufficientSponsorReserveError} When reserve is insufficient and throwOnInsufficient is true. + */ +export async function checkSponsorReserve( + sponsorAddress: string, + newEntryCount: number, + horizonUrl: string, + throwOnInsufficient: boolean = true, +): Promise { + const horizonServer = new Horizon.Server(horizonUrl); + const sponsorRecord = await horizonServer.loadAccount(sponsorAddress); + + const numSponsored = sponsorRecord.num_sponsored; + const nativeLine = sponsorRecord.balances.find( + (b) => b.asset_type === "native", + ); + const balanceStroops = nativeLine + ? xlmStringToStroops(nativeLine.balance) + : 0n; + + // Available for sponsoring = balance - minimum account reserve - current sponsorship reserve + const committedReserve = + SPONSOR_MIN_BALANCE_STROOPS + BASE_RESERVE_STROOPS * BigInt(numSponsored); + const availableStroops = + balanceStroops > committedReserve ? balanceStroops - committedReserve : 0n; + + const requiredStroops = BASE_RESERVE_STROOPS * BigInt(newEntryCount); + const sufficient = availableStroops >= requiredStroops; + + if (!sufficient && throwOnInsufficient) { + throw new InsufficientSponsorReserveError( + sponsorAddress, + availableStroops, + requiredStroops, + newEntryCount, + ); + } + + return { + sufficient, + availableStroops, + requiredStroops, + shortfallStroops: sufficient ? 0n : requiredStroops - availableStroops, + }; +} + +// --------------------------------------------------------------------------- +// Payer Readiness Check (existing) +// --------------------------------------------------------------------------- + /** * Checks whether a payer account is ready to fund an invoice. * diff --git a/src/sponsorship.ts b/src/sponsorship.ts index c2aa5f3..71dd879 100644 --- a/src/sponsorship.ts +++ b/src/sponsorship.ts @@ -68,6 +68,9 @@ export class InsufficientReserveError extends StellarSplitError { } } +import { checkSponsorReserve as _checkSponsorReserve } from "./preflightChecker.js"; +import type { SponsorshipConfig, SponsorReserveCheckResult } from "./types.js"; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -179,3 +182,26 @@ export async function buildSponsoredOnboarding( builder.setTimeout(30); return builder.build(); } + +// --------------------------------------------------------------------------- +// checkSponsorshipReserve — convenience wrapper for the preflight checker +// --------------------------------------------------------------------------- + +/** + * Convenience wrapper around {@link preflightChecker.checkSponsorReserve} + * that accepts a {@link SponsorshipConfig} object. + * + * This is exported as `checkSponsorshipReserve` from the public API. + */ +export async function checkSponsorshipReserve( + config: SponsorshipConfig, + horizonUrl: string, + options?: { throwOnInsufficient?: boolean }, +): Promise { + return _checkSponsorReserve( + config.sponsorAddress, + config.entryCount, + config.horizonUrl ?? horizonUrl, + options?.throwOnInsufficient ?? true, + ); +} diff --git a/src/types.ts b/src/types.ts index 9e6d353..0a8db6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1152,6 +1152,133 @@ export interface SetCrossChainRefParams { ref: CrossChainRef; } +// --------------------------------------------------------------------------- +// Sponsorship Configuration +// --------------------------------------------------------------------------- + +/** Configuration for sponsored-reserve onboarding flows. */ +export interface SponsorshipConfig { + /** Stellar address of the sponsoring account. */ + sponsorAddress: string; + /** Stellar address of the account being onboarded / sponsored. */ + sponsoredAddress: string; + /** Number of new ledger entries the sponsor will cover. */ + entryCount: number; + /** Optional Horizon URL override for balance checks. */ + horizonUrl?: string; +} + +/** Result of a pre-submission sponsor reserve check. */ +export interface SponsorReserveCheckResult { + /** Whether the sponsor has sufficient XLM reserve. */ + sufficient: boolean; + /** Sponsor's available XLM balance in stroops. */ + availableStroops: bigint; + /** Required XLM reserve in stroops for the new entries. */ + requiredStroops: bigint; + /** Shortfall in stroops (0 if sufficient). */ + shortfallStroops: bigint; +} + +// --------------------------------------------------------------------------- +// Invoice Record (expanded with expiresAt for timebounds) +// --------------------------------------------------------------------------- + +/** + * Expanded invoice record that includes the expiry timestamp + * used for transaction timebounds enforcement. + */ +export interface InvoiceRecord { + /** Invoice ID. */ + invoiceId: string; + /** Creator address. */ + creator: string; + /** Unix timestamp (seconds) when the invoice expires. */ + expiresAt: number; + /** Current lifecycle status. */ + status: InvoiceStatus; + /** Total amount required. */ + totalOwed: bigint; +} + +// --------------------------------------------------------------------------- +// XDR Decoder Types +// --------------------------------------------------------------------------- + +/** Supported XDR types for decoding. */ +export type XDRType = + | "TransactionEnvelope" + | "TransactionResult" + | "TransactionMeta" + | "LedgerEntry" + | "TransactionV1Envelope" + | "FeeBumpTransaction"; + +/** Decoded TransactionEnvelope as a structured JSON-safe object. */ +export interface DecodedTransactionEnvelope { + type: "TransactionEnvelope"; + tx: { + sourceAccount: string; + fee: string; + seqNum: string; + memo?: string; + operations: DecodedOperation[]; + timeBounds?: { minTime: string; maxTime: string }; + }; +} + +/** A single decoded operation within a transaction. */ +export interface DecodedOperation { + type: string; + sourceAccount?: string; + body: Record; +} + +/** Decoded TransactionResult as a structured JSON-safe object. */ +export interface DecodedTransactionResult { + type: "TransactionResult"; + feeCharged: string; + result: { + code: string; + innerResult?: Record; + }; +} + +/** Decoded TransactionMeta as a structured JSON-safe object. */ +export interface DecodedTransactionMeta { + type: "TransactionMeta"; + operations: Array<{ + changes: Array<{ + type: string; + key: string; + before?: Record; + after?: Record; + }>; + }>; +} + +/** Decoded LedgerEntry as a structured JSON-safe object. */ +export interface DecodedLedgerEntry { + type: "LedgerEntry"; + lastModifiedLedgerSeq: number; + data: { + type: string; + accountId?: string; + balance?: string; + flags?: number; + signers?: Array<{ key: string; weight: number }>; + thresholds?: { low: number; med: number; high: number }; + [key: string]: unknown; + }; +} + +/** Union type of all decoded XDR variants. */ +export type DecodedXDR = + | DecodedTransactionEnvelope + | DecodedTransactionResult + | DecodedTransactionMeta + | DecodedLedgerEntry; + // --------------------------------------------------------------------------- // Confidential Payment Types (Pedersen Commitments) // --------------------------------------------------------------------------- From 2bb96531798d5c82a58d17ecb7fde3a0e0f8793f Mon Sep 17 00:00:00 2001 From: Leke Ernest Date: Tue, 28 Jul 2026 16:22:34 +0000 Subject: [PATCH 3/5] feat: add SSE cursor position tracker for stream resumption Adds cursorTracker.ts with getCursor(streamId) / setCursor(streamId, token) for persisting the last-processed paging token across process restarts. Integrates cursor persistence into stream.ts (polling), sse.ts (SSE URL with ?cursor= param), and subscription.ts (createInvoiceSubscription). Supports injectable CursorPersistence backend for production use. --- src/cursorTracker.ts | 121 +++++++++++++++++++++++++++++++++++++++++++ src/sse.ts | 20 ++++++- src/stream.ts | 26 +++++++++- src/subscription.ts | 16 ++++++ 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 src/cursorTracker.ts diff --git a/src/cursorTracker.ts b/src/cursorTracker.ts new file mode 100644 index 0000000..9a52311 --- /dev/null +++ b/src/cursorTracker.ts @@ -0,0 +1,121 @@ +/** + * SSE / stream cursor position tracker. + * + * Persists the last-processed paging token for each named stream so + * long-running SDK processes can resume from the last processed ledger + * after restarts without duplicating events. + */ + +import type { InvoiceSnapshot } from "./snapshot.js"; + +/** In-memory cursor store. Production uses should inject a persistent backend. */ +const cursorMap = new Map(); + +/** Optional persistent store that survives process restarts. */ +let persistentStore: CursorPersistence | null = null; + +// --------------------------------------------------------------------------- +// Persistence adapter +// --------------------------------------------------------------------------- + +/** + * Backend for persisting cursor positions across restarts. + * Implementations may write to localStorage, IndexedDB, a file, etc. + */ +export interface CursorPersistence { + /** Read a cursor value by key. Returns undefined when not found. */ + get(key: string): string | undefined; + /** Write a cursor value by key. */ + set(key: string, value: string): void; + /** Remove a cursor by key. */ + delete(key: string): void; +} + +/** + * Inject a persistent cursor store. Call once at app startup to enable + * cursor survival across process restarts. + * + * @example + * ```ts + * import { configureCursorStore } from "@stellar-split/sdk"; + * + * // localStorage (browser) + * configureCursorStore({ + * get: (k) => localStorage.getItem(`cursor:${k}`) ?? undefined, + * set: (k, v) => localStorage.setItem(`cursor:${k}`, v), + * delete: (k) => localStorage.removeItem(`cursor:${k}`), + * }); + * ``` + */ +export function configureCursorStore(store: CursorPersistence): void { + persistentStore = store; +} + +/** + * Reset the cursor tracker (useful for testing). + */ +export function _resetCursorTrackerForTesting(): void { + cursorMap.clear(); + persistentStore = null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Retrieve the last persisted paging token for `streamId`. + * Returns `undefined` when no cursor has been saved. + * + * @param streamId - Unique identifier for the event stream (e.g. invoice ID or a named stream key). + */ +export function getCursor(streamId: string): string | undefined { + // Check persistent store first + if (persistentStore) { + const persisted = persistentStore.get(streamId); + if (persisted !== undefined) { + cursorMap.set(streamId, persisted); + return persisted; + } + } + return cursorMap.get(streamId); +} + +/** + * Persist the latest paging token for `streamId`. + * + * @param streamId - Unique identifier for the event stream. + * @param token - The latest processed paging token (ledger sequence or cursor). + */ +export function setCursor(streamId: string, token: string): void { + cursorMap.set(streamId, token); + if (persistentStore) { + persistentStore.set(streamId, token); + } +} + +/** + * Remove a stored cursor position for `streamId`. + * + * @param streamId - Unique identifier for the event stream. + */ +export function removeCursor(streamId: string): void { + cursorMap.delete(streamId); + if (persistentStore) { + persistentStore.delete(streamId); + } +} + +/** + * Persist the cursor from a snapshot — convenience helper that stores + * the ledger sequence recorded in an invoice snapshot as the cursor + * for the stream identified by `streamId`. + */ +export function setCursorFromSnapshot( + streamId: string, + snapshot: InvoiceSnapshot, +): void { + // We use the capturedAt timestamp as a pseudo-cursor; real usage + // would pass the ledger sequence from the subscription context. + setCursor(streamId, String(snapshot.capturedAt)); +} diff --git a/src/sse.ts b/src/sse.ts index 678b739..1abd2cf 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -2,6 +2,8 @@ * SSE-based real-time invoice event subscription. */ +import { getCursor, setCursor } from "./cursorTracker.js"; + /** The three event types emitted by the invoice SSE stream. */ export type SSEInvoiceEventType = | "payment_received" @@ -53,6 +55,10 @@ const SSE_EVENT_TYPES: SSEInvoiceEventType[] = [ * `SSEInvoiceEvent` objects to `handler`. Reconnects automatically with * exponential backoff on connection drops. * + * When a cursor has been persisted (via {@link cursorTracker.setCursor}), + * the SSE URL includes a `cursor` query parameter so the server can resume + * from the last processed event after a restart. + * * @returns An unsubscribe function that permanently stops the subscription. */ export function subscribeToInvoice( @@ -67,7 +73,11 @@ export function subscribeToInvoice( eventSourceFactory, } = options; - const url = `${baseUrl}/invoices/${encodeURIComponent(invoiceId)}/events`; + // Build URL with persisted cursor for resume-on-restart. + const streamId = `sse:invoice:${invoiceId}`; + const storedCursor = getCursor(streamId); + const cursorParam = storedCursor ? `?cursor=${encodeURIComponent(storedCursor)}` : ""; + const url = `${baseUrl}/invoices/${encodeURIComponent(invoiceId)}/events${cursorParam}`; const factory = eventSourceFactory ?? ((u: string) => new EventSource(u) as EventSourceLike); @@ -101,6 +111,14 @@ export function subscribeToInvoice( backoff = initialBackoffMs; // reset on successful message + // Persist event timestamp as cursor for resume-on-restart. + try { + const eventTs = Date.now().toString(); + setCursor(streamId, eventTs); + } catch { + // Best-effort cursor persistence + } + handler({ type: raw.type as SSEInvoiceEventType, invoiceId: raw.invoiceId, diff --git a/src/stream.ts b/src/stream.ts index d1df216..61b22de 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -2,6 +2,7 @@ import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; import type { InvoiceEventCallbacks, Payment } from "./types.js"; import type { SSEInvoiceEvent } from "./sse.js"; import { TooManySubscriptionsError } from "./errors.js"; +import { getCursor, setCursor } from "./cursorTracker.js"; /** Maximum concurrent subscriptions allowed. */ const MAX_SUBSCRIPTIONS = 10; @@ -126,6 +127,16 @@ export function subscribeToInvoice( handlerOrCallbacks: ((events: SSEInvoiceEvent[]) => void) | InvoiceEventCallbacks, intervalMs: number = 5000 ): () => void { + // Resume from last persisted cursor when available. + const streamId = `contract:${contractId}:invoice:${invoiceId}`; + const storedCursor = getCursor(streamId); + let resumeLedger: number | undefined; + if (storedCursor) { + const parsed = parseInt(storedCursor, 10); + if (!isNaN(parsed) && parsed > 0) { + resumeLedger = parsed; + } + } // Check subscription limit if (subscriptionCount >= MAX_SUBSCRIPTIONS) { throw new TooManySubscriptionsError(MAX_SUBSCRIPTIONS); @@ -177,8 +188,12 @@ export function subscribeToInvoice( try { if (lastLedger === null) { - const latest = await server.getLatestLedger(); - lastLedger = latest.sequence; + if (resumeLedger !== undefined) { + lastLedger = resumeLedger; + } else { + const latest = await server.getLatestLedger(); + lastLedger = latest.sequence; + } } const response = await server.getEvents({ @@ -245,6 +260,13 @@ export function subscribeToInvoice( } lastLedger = maxLedger + 1; + + // Persist the latest processed ledger as the cursor for resume-on-restart. + try { + setCursor(streamId, String(lastLedger)); + } catch { + // Best-effort cursor persistence + } } catch { // Silently continue on network errors } diff --git a/src/subscription.ts b/src/subscription.ts index 2588d6f..fe9bb67 100644 --- a/src/subscription.ts +++ b/src/subscription.ts @@ -1,6 +1,7 @@ import { rpc as SorobanRpc, scValToNative, xdr } from "@stellar/stellar-sdk"; import type { InvoiceEvent, Subscription, SubscriptionOptions } from "./types.js"; import { StellarSplitError } from "./errors.js"; +import { getCursor, setCursor } from "./cursorTracker.js"; /** Error codes for subscription errors. */ export const SUBSCRIPTION_ERROR_CODES = { @@ -366,6 +367,14 @@ export function createInvoiceSubscription( activeSubscriptions.add(state); } + // Resume from persisted cursor when available + const streamId = `subscription:${contractId}:${invoiceId}`; + const storedCursor = getCursor(streamId); + const cursorLedger = storedCursor ? parseInt(storedCursor, 10) : NaN; + if (!isNaN(cursorLedger) && cursorLedger > 0 && state.lastLedger === null) { + state.lastLedger = cursorLedger; + } + const emitLifecycle = (event: SubscriptionLifecycleEvent) => { config.onLifecycleEvent?.(event); }; @@ -454,6 +463,13 @@ export function createInvoiceSubscription( state.backoffMs = config.initialBackoffMs; state.lastLedger = maxLedger + 1; + // Persist the cursor for resume-on-restart. + try { + setCursor(streamId, String(state.lastLedger)); + } catch { + // Best-effort + } + for (const evt of newEvents) { state.pendingCallback = true; try { From fadc4dcfb49c81f7769f8a5731e6814214d938ab Mon Sep 17 00:00:00 2001 From: Leke Ernest Date: Tue, 28 Jul 2026 16:22:34 +0000 Subject: [PATCH 4/5] feat: add XDR decoder for structured transaction logging Adds decodeXDR() that translates base64-encoded Stellar XDR into structured JSON objects safe for logging, audit trails, and developer UIs. Decodes TransactionEnvelope, TransactionResult, TransactionMeta, and LedgerEntry using @stellar/stellar-sdk xdr namespace. Integrates with auditLogger.ts via logWithXdr() and logAndDecodeXdr() methods that auto-detect and decode XDR payloads in audit entries. --- src/auditLogger.ts | 70 +++++++++++ src/xdrDecoder.ts | 306 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 src/xdrDecoder.ts diff --git a/src/auditLogger.ts b/src/auditLogger.ts index fda4b5a..dc2361c 100644 --- a/src/auditLogger.ts +++ b/src/auditLogger.ts @@ -1,4 +1,6 @@ import { truncateAddress } from "./utils.js"; +import { decodeXDR } from "./xdrDecoder.js"; +import type { XDRType, DecodedXDR } from "./types.js"; export interface AuditEntry { timestamp: number; @@ -6,10 +8,18 @@ export interface AuditEntry { params: Record; success: boolean; durationMs: number; + /** Optional decoded XDR payload attached to this audit entry. */ + decodedXdr?: DecodedXDR; } const STELLAR_ADDRESS_RE = /^G[A-Z0-9]{55}$/; +/** Detect if a string value looks like base64-encoded XDR. */ +const XDR_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +/** Heuristic minimum length for XDR base64 strings (at least ~40 chars for a minimal tx). */ +const MIN_XDR_LENGTH = 40; + export class AuditLogger { private readonly sink: (entry: AuditEntry) => void; @@ -31,4 +41,64 @@ export class AuditLogger { ]) ); } + + /** + * Log an entry with automatic XDR decoding. + * + * When `xdrPayload` is provided, it is decoded and attached as `decodedXdr`. + * This produces structured JSON safe for log aggregation, audit trails, + * and developer UIs — no external XDR converters needed. + * + * @param entry - Base audit entry. + * @param xdrPayload - Base64-encoded XDR to decode (e.g. a transaction envelope). + * @param xdrType - The expected XDR type. + */ + logWithXdr( + entry: AuditEntry, + xdrPayload: string, + xdrType: XDRType, + ): void { + try { + if ( + xdrPayload.length >= MIN_XDR_LENGTH && + XDR_BASE64_RE.test(xdrPayload) + ) { + entry.decodedXdr = decodeXDR(xdrPayload, xdrType); + } + } catch { + // Decoding best-effort; never fail an audit log write. + } + this.log(entry); + } + + /** + * Auto-detect and decode XDR payloads embedded in audit params. + * + * Scans `entry.params` for keys matching known XDR field names + * ("xdr", "txXdr", "envelopeXdr", "resultXdr", "metaXdr") + * and attempts to decode them, attaching the result to `entry.decodedXdr`. + */ + logAndDecodeXdr(entry: AuditEntry): void { + const xdrKeys: Array<{ key: string; type: XDRType }> = [ + { key: "xdr", type: "TransactionEnvelope" }, + { key: "txXdr", type: "TransactionEnvelope" }, + { key: "envelopeXdr", type: "TransactionEnvelope" }, + { key: "resultXdr", type: "TransactionResult" }, + { key: "metaXdr", type: "TransactionMeta" }, + ]; + + for (const { key, type } of xdrKeys) { + const value = entry.params[key]; + if (typeof value === "string" && value.length >= MIN_XDR_LENGTH) { + try { + entry.decodedXdr = decodeXDR(value, type); + break; // Decode the first match only + } catch { + // continue to next key + } + } + } + + this.log(entry); + } } diff --git a/src/xdrDecoder.ts b/src/xdrDecoder.ts new file mode 100644 index 0000000..d1801a9 --- /dev/null +++ b/src/xdrDecoder.ts @@ -0,0 +1,306 @@ +/** + * XDR Decoder — translates opaque Stellar XDR binary (base64) into + * structured JSON objects safe for logging, audit trails, and developer UIs. + * + * Uses the @stellar/stellar-sdk `xdr` namespace to decode all major XDR types. + */ + +import { xdr, StrKey } from "@stellar/stellar-sdk"; +import type { + XDRType, + DecodedXDR, + DecodedTransactionEnvelope, + DecodedTransactionResult, + DecodedTransactionMeta, + DecodedLedgerEntry, + DecodedOperation, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Convert a Stellar address (raw AccountId Ed25519 bytes) to a G... string. */ +function accountIdToString(accountId: xdr.AccountId): string { + try { + const ed25519 = accountId.ed25519(); + if (ed25519) { + return StrKey.encodeEd25519PublicKey(ed25519); + } + // Fallback: use XDR base64 representation + return accountId.toXDR("base64"); + } catch { + return "unknown"; + } +} + +/** Safely stringify a bigint or number for JSON compatibility. */ +function bigintToString(value: bigint | number | string): string { + return typeof value === "bigint" ? value.toString() : String(value); +} + +/** Decode a single Stellar operation into a structured object. */ +function decodeOperation( + body: xdr.OperationBody, + sourceAccount: xdr.MuxedAccount | null, +): DecodedOperation { + const bodyRaw: Record = {}; + try { + const serialized = body.toXDR("base64"); + bodyRaw._xdr = serialized; + } catch { + // best-effort + } + + return { + type: body.switch().name, + sourceAccount: sourceAccount + ? accountIdToString(sourceAccount as unknown as xdr.AccountId) + : undefined, + body: bodyRaw, + }; +} + +// --------------------------------------------------------------------------- +// Decoders +// --------------------------------------------------------------------------- + +/** + * Decode a base64-encoded TransactionEnvelope XDR. + */ +function decodeTransactionEnvelope( + envelope: xdr.TransactionEnvelope, +): DecodedTransactionEnvelope { + let tx: DecodedTransactionEnvelope["tx"] | null = null; + + try { + if (envelope.switch() === xdr.EnvelopeType.envelopeTypeTxV0()) { + const v0 = envelope.v0(); + tx = { + sourceAccount: accountIdToString(v0.tx().sourceAccountEd25519() as unknown as xdr.AccountId), + fee: v0.tx().fee().toString(), + seqNum: v0.tx().seqNum().toString(), + memo: undefined, + operations: (v0.tx().operations() ?? []).map((op) => + decodeOperation(op.body(), op.sourceAccount()), + ), + timeBounds: (() => { + try { + const tb = v0.tx().timeBounds(); + if (tb) { + return { + minTime: tb.minTime().toString(), + maxTime: tb.maxTime().toString(), + }; + } + } catch { /* no timebounds */ } + return undefined; + })(), + }; + } else if (envelope.switch() === xdr.EnvelopeType.envelopeTypeTx()) { + const v1 = envelope.v1(); + tx = { + sourceAccount: accountIdToString(v1.tx().sourceAccount() as unknown as xdr.AccountId), + fee: v1.tx().fee().toString(), + seqNum: v1.tx().seqNum().toString(), + memo: (() => { + try { + return v1.tx().memo().switch().name; + } catch { + return undefined; + } + })(), + operations: (v1.tx().operations() ?? []).map((op) => + decodeOperation(op.body(), op.sourceAccount()), + ), + timeBounds: (() => { + try { + const tb = v1.tx().timeBounds(); + if (tb) { + return { + minTime: tb.minTime().toString(), + maxTime: tb.maxTime().toString(), + }; + } + } catch { /* no timebounds */ } + return undefined; + })(), + }; + } else if (envelope.switch() === xdr.EnvelopeType.envelopeTypeTxFeeBump()) { + const fb = envelope.feeBump(); + const inner = fb.tx().innerTx().v1(); + tx = { + sourceAccount: accountIdToString(fb.tx().feeSource() as unknown as xdr.AccountId), + fee: fb.tx().fee().toString(), + seqNum: "0", + operations: (inner?.tx().operations() ?? []).map((op) => + decodeOperation(op.body(), op.sourceAccount()), + ), + timeBounds: undefined, + }; + } + } catch { + // graceful fallback + } + + return { + type: "TransactionEnvelope", + tx: tx ?? { + sourceAccount: "unknown", + fee: "0", + seqNum: "0", + operations: [], + }, + }; +} + +/** + * Decode a base64-encoded TransactionResult XDR. + */ +function decodeTransactionResult( + result: xdr.TransactionResult, +): DecodedTransactionResult { + let resultCode = "unknown"; + let innerResult: Record | undefined; + + try { + resultCode = result.result().switch().name; + const inner = result.result().innerResult(); + if (inner) { + innerResult = { + switch: inner.value().switch().name, + }; + } + } catch { + // best-effort + } + + return { + type: "TransactionResult", + feeCharged: result.feeCharged().toString(), + result: { + code: resultCode, + innerResult, + }, + }; +} + +/** + * Decode a base64-encoded TransactionMeta XDR. + */ +function decodeTransactionMeta( + meta: xdr.TransactionMeta, +): DecodedTransactionMeta { + const operations: DecodedTransactionMeta["operations"] = []; + + try { + const opsMeta = meta.v3()?.operations() ?? []; + for (const opMeta of opsMeta) { + const changes = (opMeta.changes() ?? []).map((change) => ({ + type: change.switch().name, + key: change.key()?.toXDR("base64") ?? "unknown", + })); + operations.push({ changes }); + } + } catch { + // graceful: return empty operations list + } + + return { type: "TransactionMeta", operations }; +} + +/** + * Decode a base64-encoded LedgerEntry XDR. + */ +function decodeLedgerEntry( + entry: xdr.LedgerEntry, +): DecodedLedgerEntry { + const data: DecodedLedgerEntry["data"] = { type: entry.data().switch().name }; + + try { + const account = entry.data().account(); + if (account) { + data.accountId = account.accountId().toXDR("base64"); + data.balance = account.balance().toString(); + data.flags = account.flags(); + data.signers = (account.signers() ?? []).map((s) => ({ + key: s.key().toXDR("base64"), + weight: s.weight(), + })); + data.thresholds = { + low: account.thresholds()[0], + med: account.thresholds()[1], + high: account.thresholds()[2], + }; + } + } catch { + // best-effort + } + + return { + type: "LedgerEntry", + lastModifiedLedgerSeq: entry.lastModifiedLedgerSeq(), + data, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Decode a base64-encoded XDR string into a structured JSON-safe object. + * + * @param xdrBase64 - Base64-encoded XDR string. + * @param type - The expected XDR type. + * @returns A typed DecodedXDR union object. + * + * @example + * ```ts + * import { decodeXDR } from "@stellar-split/sdk"; + * + * const decoded = decodeXDR(rawTxEnvelope, "TransactionEnvelope"); + * console.log(decoded.tx.sourceAccount, decoded.tx.operations.length); + * ``` + */ +export function decodeXDR(xdrBase64: string, type: XDRType): DecodedXDR { + const buffer = Buffer.from(xdrBase64, "base64"); + + switch (type) { + case "TransactionEnvelope": { + const envelope = xdr.TransactionEnvelope.fromXDR(buffer); + return decodeTransactionEnvelope(envelope); + } + case "TransactionResult": { + const result = xdr.TransactionResult.fromXDR(buffer); + return decodeTransactionResult(result); + } + case "TransactionMeta": { + const meta = xdr.TransactionMeta.fromXDR(buffer); + return decodeTransactionMeta(meta); + } + case "LedgerEntry": { + const entry = xdr.LedgerEntry.fromXDR(buffer); + return decodeLedgerEntry(entry); + } + case "TransactionV1Envelope": + case "FeeBumpTransaction": { + // Graceful: decode as TransactionEnvelope and tag appropriately + try { + const envelope = xdr.TransactionEnvelope.fromXDR(buffer); + const decoded = decodeTransactionEnvelope(envelope); + return { + ...decoded, + type: "TransactionEnvelope", + }; + } catch { + return { + type: "TransactionEnvelope", + tx: { sourceAccount: "unknown", fee: "0", seqNum: "0", operations: [] }, + }; + } + } + default: + throw new Error(`Unsupported XDR type: ${type}`); + } +} From 234c47fb52f56545d47a956634b43e8a45189593 Mon Sep 17 00:00:00 2001 From: Leke Ernest Date: Tue, 28 Jul 2026 16:22:43 +0000 Subject: [PATCH 5/5] feat: export all new modules (sponsorship, expiry, cursor, xdr) from index --- src/index.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 55a1aaa..18de4c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,6 +168,10 @@ export { isIPFSConfigError, ShutdownInProgressError, isShutdownInProgressError, + InsufficientSponsorReserveError, + isInsufficientSponsorReserveError, + PaymentExpiredError, + isPaymentExpiredError, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -333,6 +337,16 @@ export type { AdminFreezeResult, AdminUnfreezeResult, TransitionRecord, + SponsorshipConfig, + SponsorReserveCheckResult, + InvoiceRecord, + XDRType, + DecodedXDR, + DecodedTransactionEnvelope, + DecodedTransactionResult, + DecodedTransactionMeta, + DecodedLedgerEntry, + DecodedOperation, } from "./types.js"; export { InvalidTransitionError } from "./types.js"; @@ -360,11 +374,35 @@ export type { RpcClient } from "./rpcClient.js"; export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js"; export type { VersionInfo } from "./types.js"; -export { checkPayerReadiness } from "./preflightChecker.js"; -export type { PayerReadinessResult, PayerReadinessReason } from "./preflightChecker.js"; +export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve } from "./preflightChecker.js"; +export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck } from "./preflightChecker.js"; export { getSuggestion } from "./errorSuggestions.js"; +// --------------------------------------------------------------------------- +// XDR Decoder — structured logging of Stellar XDR +// --------------------------------------------------------------------------- + +export { decodeXDR } from "./xdrDecoder.js"; + +// --------------------------------------------------------------------------- +// SSE Cursor Tracker — persistent cursor for stream resumption +// --------------------------------------------------------------------------- + +export { + configureCursorStore, + getCursor, + setCursor, + removeCursor, + setCursorFromSnapshot, + _resetCursorTrackerForTesting, +} from "./cursorTracker.js"; +export type { CursorPersistence } from "./cursorTracker.js"; + +// --------------------------------------------------------------------------- +// Stream + SSE subscription helpers +// --------------------------------------------------------------------------- + // Real-time invoice event subscription (Issue #417) export { createInvoiceSubscription } from "./subscription.js"; export type { @@ -635,6 +673,7 @@ export { buildSponsoredOnboarding, MissingSponsorAccountError, InsufficientReserveError, + checkSponsorshipReserve, } from "./sponsorship.js"; export {