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/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/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/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 { 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/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 { 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); 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) // --------------------------------------------------------------------------- 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}`); + } +}