From 0d0eca57dcff5fc8134462797dd8f7397ebb830e Mon Sep 17 00:00:00 2001 From: terngunan Date: Fri, 25 Sep 2026 13:46:31 +0000 Subject: [PATCH] feat(client): clone overrides + lineage, dry-run simulation, event streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the remaining acceptance criteria for four SDK issues. #850 — cloneInvoice now accepts a bigint (or string) source ID and InvoiceParamOverrides (title, deadline, targetAmount, recipients). Overrides are normalised and validated like createInvoice before submission and mapped onto the clone_invoice override map. Adds getLineage(invoiceId) returning the root -> leaf ancestor chain as bigint[]. #844 — adds generic client.simulate(method, params) returning SimulationResult { success, error?, fee, cpuInsns, memBytes, footprint } and supports { simulate: true } on createInvoice, pay, releaseGroup and refundInvoice. The RPC SimulationResult type is exported from the package; the sandbox result type is re-exported as SandboxSimulationResult to avoid a name collision. #842 — adds subscribeInvoice(invoiceId, callback, options) returning a Subscription backed by Soroban getEvents polling with ledger+topic deduplication and exponential-backoff reconnection. #843 — documents the existing retry (exponential backoff + jitter) and circuit breaker (CLOSED/OPEN/HALF-OPEN, circuit:open|close|half-open events) resilience layer in the README and wires its test suite into `npm test`. Tests: adds test/clientIssueFixes.test.ts (15 cases) and runs resilience.test.ts in CI. `npm test` passes (153 tests). No new TypeScript errors are introduced (verified against the pre-change baseline). closes #850 closes #844 closes #842 closes #843 --- README.md | 85 ++++++- package.json | 2 +- src/client.ts | 440 ++++++++++++++++++++++++++++++++-- src/index.ts | 12 +- src/types.ts | 53 ++++ test/clientIssueFixes.test.ts | 348 +++++++++++++++++++++++++++ 6 files changed, 919 insertions(+), 21 deletions(-) create mode 100644 test/clientIssueFixes.test.ts diff --git a/README.md b/README.md index 762d7fe..36ef5c6 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,91 @@ new StellarSplitClient(config: StellarSplitClientConfig) | Method | Returns | Description | |--------|---------|-------------| -| `createInvoice(params)` | `Promise<{ invoiceId, txHash }>` | Create a new invoice | -| `pay(params)` | `Promise<{ txHash }>` | Pay toward an invoice | +| `createInvoice(params)` | `Promise<{ invoiceId, txHash }>` | Create a new invoice (`{ simulate: true }` returns a `SimulationResult`) | +| `pay(params)` | `Promise<{ txHash }>` | Pay toward an invoice (`{ simulate: true }` returns a `SimulationResult`) | | `getInvoice(id)` | `Promise` | Fetch invoice by ID | | `getPayments(id)` | `Promise` | Fetch payments for an invoice | +| `cloneInvoice(sourceId, overrides?)` | `Promise` | Clone an invoice with optional field overrides; returns the new invoice ID | +| `getLineage(invoiceId)` | `Promise` | Ancestor chain (root → … → invoice) as bigint IDs | +| `subscribeInvoice(invoiceId, cb, options?)` | `Subscription` | Stream invoice events with dedup + auto-reconnect | +| `simulate(method, params)` | `Promise` | Dry-run any contract method via Soroban simulation RPC | + +### Dry-Run Simulation + +Simulate any mutating transaction against Soroban RPC to get fee and resource +estimates without consuming a sequence number. `createInvoice`, `pay`, +`releaseGroup` and `refundInvoice` accept a `{ simulate: true }` option; the +generic `simulate()` method works for any contract entry point (including +`release`, `approveRelease` and `cloneInvoice`). + +```typescript +const result = await client.simulate("createInvoice", { + creator: publicKey, + recipients: [{ address: "GABC...", amount: parseAmount("100") }], + token: "USDC_CONTRACT_ADDRESS", + deadline: deadlineFromDays(7), +}); + +console.log(result.success, result.fee, result.cpuInsns, result.memBytes); +console.log(result.footprint); // { readBytes, writeBytes, readLedgerEntries, writeLedgerEntries } + +// Or inline on a supported method: +const simulated = await client.createInvoice({ ...params, simulate: true }); +if (simulated.success) console.log(`Estimated fee: ${simulated.fee}`); +``` + +### Cloning Invoices + +```typescript +// Clone with optional field overrides (validated like createInvoice) +const newId = await client.cloneInvoice(42n, { + title: "Rebalanced split", + deadline: deadlineFromDays(14), + targetAmount: parseAmount("250"), + recipients: ["GABC...", "GDEF..."], +}); + +// Inspect the full ancestor chain (root first) +const lineage = await client.getLineage(newId); // [1n, 2n, 42n, newId] +``` + +### Real-Time Invoice Events + +```typescript +const subscription = client.subscribeInvoice(42n, (event) => { + console.log(event.type, event.invoiceId); // payment | released | refunded | ... +}); + +// Later — stop polling and release timers +subscription.unsubscribe(); +``` + +Polling uses Soroban `getEvents` every `pollIntervalMs` (default 3000ms), +deduplicates by ledger sequence + topic hash, and reconnects with exponential +backoff (up to `maxRetries`, default 5) before emitting an `error` lifecycle +event. + +### Resilience: Retries & Circuit Breaker + +All RPC calls are wrapped with exponential backoff + jitter and a circuit +breaker that opens after N consecutive failures and auto-resets after a +cooldown. Non-retryable errors (invalid input, unauthorized) bypass retries. + +```typescript +const client = new StellarSplitClient({ + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "YOUR_CONTRACT_ID", + circuitBreaker: { + retry: { maxRetries: 5, baseDelayMs: 250, maxDelayMs: 10_000, jitter: true }, + breaker: { failureThreshold: 5, resetTimeoutMs: 30_000 }, + }, +}); + +client.on("circuit:open", () => console.warn("RPC circuit opened")); +client.on("circuit:half-open", () => console.warn("RPC circuit probing")); +client.on("circuit:close", () => console.info("RPC circuit closed")); +``` ### Wallet Helpers diff --git a/package.json b/package.json index 32ad21d..39bdc17 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest run test/client.test.ts test/retryPolicy.test.ts", + "test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/clientIssueFixes.test.ts test/resilience.test.ts", "test:ui": "vitest run test/ui/", "test:all": "vitest run", "test:e2e": "vitest run test/e2e", diff --git a/src/client.ts b/src/client.ts index 8b44892..2509957 100644 --- a/src/client.ts +++ b/src/client.ts @@ -84,7 +84,7 @@ import type { PaymentReceipt } from "./receipt.js"; import { checkInvoiceExpiry, checkPayerReadiness } from "./preflightChecker.js"; import { InvoiceCloneabilityValidator } from "./preflight/InvoiceCloneabilityValidator.js"; import { createInvoiceSubscription } from "./subscription.js"; -import type { Subscription, InvoiceEvent, SubscriptionOptions } from "./types.js"; +import type { Subscription, InvoiceEvent, SubscriptionOptions, SimulationResult, LedgerFootprint } from "./types.js"; import { getSubscriptionManager } from "./streaming/SubscriptionManager.js"; import { destroySubscriptionManager } from "./streaming/SubscriptionManager.js"; import type { SubscriptionOptions as SubscriptionManagerOptions } from "./types/events.js"; @@ -120,6 +120,7 @@ import type { InvoiceEventCallbacks, InvoiceExt, InvoiceGroup, + InvoiceParamOverrides, InvoiceReceipt, InvoiceStatus, PaginatedResult, @@ -2516,9 +2517,22 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - The parameters for the method. * @throws {Error} If the method fails. */ + async createInvoice( + params: CreateInvoiceParams & { simulate: true }, + ): Promise; async createInvoice( params: CreateInvoiceParams, - ): Promise<{ invoiceId: string; txHash: string }> { + ): Promise<{ invoiceId: string; txHash: string }>; + async createInvoice( + params: CreateInvoiceParams, + ): Promise<{ invoiceId: string; txHash: string } | SimulationResult> { + // Issue #844 — `{ simulate: true }` performs a dry-run instead of submitting. + if (params.simulate) { + return this.simulate( + "create_invoice", + params as unknown as Record, + ); + } return this._withTelemetry( "createInvoice", { @@ -2698,11 +2712,15 @@ export class StellarSplitClient extends TypedEventEmitter { * @throws {InvoiceNotFoundError} If the source invoice does not exist. */ async cloneInvoice( - sourceId: string, - overrides: CloneOverrides = {}, + sourceId: string | bigint, + rawOverrides: CloneOverrides & InvoiceParamOverrides = {}, ): Promise { const startTime = Date.now(); - const sourceInvoice = await this.getInvoice(sourceId); + const sourceInvoice = await this.getInvoice(sourceId.toString()); + const overrides = this._normalizeCloneOverrides( + rawOverrides, + sourceInvoice.recipients.length, + ); // ------------------------------------------------------------------- // Cloneability pre-flight validation (#486) @@ -2779,18 +2797,22 @@ export class StellarSplitClient extends TypedEventEmitter { key: nativeToScVal("new_recipients", { type: "symbol" }) as xdr.ScVal, val: xdr.ScVal.scvVec( overrides.newRecipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r, { type: "address" }), ), ) as xdr.ScVal, }), ); } + // Optional title override (issue #850). Serialised as `new_title`; the + // contract ignores unknown override keys, so older deployments are safe. + if (overrides.newTitle !== undefined) { + mapEntries.push( + new xdr.ScMapEntry({ + key: nativeToScVal("new_title", { type: "symbol" }) as xdr.ScVal, + val: nativeToScVal(overrides.newTitle, { type: "string" }) as xdr.ScVal, + }), + ); + } // new_overflow_behavior is a Vec on the contract side (0 or 1 // elements), not an Option — the contract can't represent Option in a // #[contracttype] struct, so the key is always sent. @@ -2865,8 +2887,8 @@ export class StellarSplitClient extends TypedEventEmitter { const optimisticInvoice: Invoice = { ...sourceInvoice, id, - clonedFrom: sourceId, - parentInvoiceId: sourceId, + clonedFrom: sourceId.toString(), + parentInvoiceId: sourceId.toString(), cloneDepth, funded: 0n, payments: [], @@ -2898,12 +2920,355 @@ export class StellarSplitClient extends TypedEventEmitter { * @throws {Error} If the method fails. */ if (error instanceof Error && error.message.includes("not found")) { - throw new InvoiceNotFoundError(sourceId); + throw new InvoiceNotFoundError(sourceId.toString()); } throw error; } } + // --------------------------------------------------------------------------- + // Issue #850 — clone lineage + // --------------------------------------------------------------------------- + + /** + * Return the full ancestor chain for an invoice, ordered root → … → invoiceId. + * + * The chain always ends with `invoiceId` itself. A non-cloned invoice returns + * a single-element array containing its own ID. + * + * @param invoiceId - The invoice whose lineage should be resolved. + * @returns Invoice IDs as `bigint[]` in root-to-leaf order. + * @throws {CloneChainTooDeepError} If the clone chain is cyclic or too deep. + */ + async getLineage(invoiceId: string | bigint): Promise { + const chain = await this.resolveCloneChain(invoiceId.toString()); + return chain.map((invoice) => BigInt(invoice.id)); + } + + /** + * Normalise `InvoiceParamOverrides` onto the contract's `CloneOverrides` + * shape, applying the same field validation as `createInvoice` (issue #850). + * + * @param input - Raw overrides supplied by the caller. + * @param recipientCount - Number of recipients on the source invoice. + * @returns Validated contract-level overrides. + * @throws {ValidationError} If any override is malformed. + */ + private _normalizeCloneOverrides( + input: CloneOverrides & InvoiceParamOverrides, + recipientCount: number, + ): CloneOverrides { + const out: CloneOverrides = { ...input }; + + if (input.title !== undefined) { + if (typeof input.title !== "string" || input.title.trim().length === 0) { + throw new ValidationError( + "cloneInvoice override `title` must be a non-empty string.", + ); + } + out.newTitle = input.title.trim(); + } + + if (input.deadline !== undefined) { + const nowSeconds = Math.floor(Date.now() / 1000); + if ( + typeof input.deadline !== "number" || + !Number.isFinite(input.deadline) || + input.deadline <= nowSeconds + ) { + throw new ValidationError( + "cloneInvoice override `deadline` must be a future unix timestamp in seconds.", + ); + } + out.newDeadline = input.deadline; + } + + if (input.recipients !== undefined) { + if ( + !Array.isArray(input.recipients) || + input.recipients.length === 0 || + input.recipients.some( + (address) => + typeof address !== "string" || + !/^[GC][A-Z2-7]{55}$/.test(address), + ) + ) { + throw new ValidationError( + "cloneInvoice override `recipients` must be a non-empty array of valid Stellar addresses.", + ); + } + out.newRecipients = [...input.recipients]; + } + + if (input.targetAmount !== undefined) { + if (typeof input.targetAmount !== "bigint" || input.targetAmount <= 0n) { + throw new ValidationError( + "cloneInvoice override `targetAmount` must be a positive bigint (stroops).", + ); + } + const count = out.newRecipients?.length ?? recipientCount; + if (count <= 0) { + throw new ValidationError( + "cloneInvoice override `targetAmount` requires at least one recipient.", + ); + } + // Split the total evenly, assigning any remainder stroops to the first + // recipient so the parts always sum back to `targetAmount`. + const base = input.targetAmount / BigInt(count); + const remainder = input.targetAmount - base * BigInt(count); + out.newAmounts = Array.from({ length: count }, (_, index) => + index === 0 ? base + remainder : base, + ); + if (out.newAmounts.some((amount) => amount <= 0n)) { + throw new ValidationError( + "cloneInvoice override `targetAmount` is too small to split across recipients.", + ); + } + } + + if ( + out.newAmounts !== undefined && + out.newRecipients !== undefined && + out.newAmounts.length !== out.newRecipients.length + ) { + throw new ValidationError( + "cloneInvoice overrides must provide one amount per recipient.", + ); + } + + return out; + } + + // --------------------------------------------------------------------------- + // Issue #842 — real-time invoice event streaming + // --------------------------------------------------------------------------- + + /** + * Subscribe to real-time state changes for a single invoice. + * + * Polls the Soroban `getEvents` RPC (`pollIntervalMs`, default 3000ms), + * deduplicates events by ledger sequence + topic hash, and reconnects with + * exponential backoff (up to `maxRetries`, default 5) before emitting an + * `error` lifecycle event. + * + * @param invoiceId - The invoice ID to watch. + * @param callback - Invoked once per new {@link InvoiceEvent}. + * @param options - Optional poll/backoff/storage overrides. + * @returns A {@link Subscription} whose `unsubscribe()` stops all polling. + * @example + * const sub = client.subscribeInvoice(42n, (event) => console.log(event.type)); + * // later + * sub.unsubscribe(); + */ + subscribeInvoice( + invoiceId: bigint | string, + callback: (event: InvoiceEvent) => void, + options: SubscriptionOptions = {}, + ): Subscription { + return createInvoiceSubscription( + this.server, + this.config.contractId, + invoiceId.toString(), + callback, + options, + ); + } + + // --------------------------------------------------------------------------- + // Issue #844 — generic dry-run simulation + // --------------------------------------------------------------------------- + + /** + * Simulate any contract method against Soroban RPC without submitting a + * transaction or consuming a sequence number. + * + * Accepts either a camelCase client method name (`createInvoice`, `pay`, + * `release`, `refund`, `approveRelease`, `cloneInvoice`) or the raw contract + * entry point (`create_invoice`, `pay`, `release_invoice`, `refund_invoice`, + * `approve_release`, `clone_invoice`). + * + * @param method - Method name or contract entry point to simulate. + * @param params - Parameters for the method. + * @returns A {@link SimulationResult}; `success` is `false` (rather than a + * throw) when the contract rejects the call. + */ + async simulate( + method: string, + params: Record = {}, + ): Promise { + const operation = this._buildSimulationOperation(method, params); + const source = String( + params.creator ?? + params.payer ?? + params.source ?? + params.approver ?? + "", + ); + if (!/^G[A-Z2-7]{55}$/.test(source)) { + throw new ValidationError( + "simulate() requires a Stellar account `source` (or `creator`/`payer`) to build the dry-run transaction.", + ); + } + const account = await this.server + .getAccount(source) + .catch(() => new Account(source, "0")); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simResult = await this.server.simulateTransaction(tx); + return this._toSimulationResult(simResult); + } + + /** Convert a raw Soroban simulation response into a {@link SimulationResult}. */ + private _toSimulationResult( + sim: SorobanRpc.Api.SimulateTransactionResponse, + ): SimulationResult { + const emptyFootprint: LedgerFootprint = { + readBytes: 0n, + writeBytes: 0n, + readLedgerEntries: 0n, + writeLedgerEntries: 0n, + }; + + if (SorobanRpc.Api.isSimulationError(sim)) { + return { + success: false, + error: (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error, + fee: 0n, + cpuInsns: 0n, + memBytes: 0n, + footprint: emptyFootprint, + }; + } + + const success = sim as SorobanRpc.Api.SimulateTransactionSuccessResponse; + let footprint = emptyFootprint; + try { + const resources = success.transactionData.build().resources(); + const ledgerFootprint = resources.footprint(); + footprint = { + readBytes: BigInt(resources.readBytes()), + writeBytes: BigInt(resources.writeBytes()), + readLedgerEntries: BigInt(ledgerFootprint.readOnly().length), + writeLedgerEntries: BigInt(ledgerFootprint.readWrite().length), + }; + } catch { + // Restore/error responses may omit `transactionData`; leave zeros. + } + + const cost = (success as unknown as { + cost?: { cpuInsns?: string; memBytes?: string }; + }).cost; + + return { + success: true, + fee: BigInt(success.minResourceFee ?? "0"), + cpuInsns: BigInt(cost?.cpuInsns ?? 0), + memBytes: BigInt(cost?.memBytes ?? 0), + footprint, + }; + } + + /** + * Build the contract operation for {@link simulate} from a method name and + * a plain parameter object. + */ + private _buildSimulationOperation( + method: string, + params: Record, + ): xdr.Operation { + const aliases: Record = { + createInvoice: "create_invoice", + pay: "pay", + release: "release_invoice", + releaseInvoice: "release_invoice", + releaseGroup: "release_invoice_group", + refund: "refund_invoice", + refundInvoice: "refund_invoice", + approveRelease: "approve_release", + cloneInvoice: "clone_invoice", + }; + const entryPoint = aliases[method] ?? method; + + switch (entryPoint) { + case "create_invoice": { + const recipients = (params.recipients ?? []) as Array<{ + address: string; + amount: bigint; + }>; + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new ValidationError( + "simulate(create_invoice) requires a non-empty `recipients` array.", + ); + } + return this.contract.call( + "create_invoice", + nativeToScVal(String(params.creator), { type: "address" }), + xdr.ScVal.scvVec( + recipients.map((r) => nativeToScVal(r.address, { type: "address" })), + ), + xdr.ScVal.scvVec( + recipients.map((r) => nativeToScVal(r.amount, { type: "i128" })), + ), + nativeToScVal(String(params.token), { type: "address" }), + nativeToScVal(Number(params.deadline), { type: "u64" }), + ); + } + case "pay": + return this.contract.call( + "pay", + nativeToScVal(String(params.payer), { type: "address" }), + nativeToScVal(BigInt(params.invoiceId as string | number | bigint), { + type: "u64", + }), + nativeToScVal(params.amount, { type: "i128" }), + nativeToScVal(Boolean(params.donateOnFailure ?? false), { + type: "bool", + }), + ); + case "clone_invoice": + return this.contract.call( + "clone_invoice", + nativeToScVal( + BigInt( + (params.sourceId ?? params.invoiceId) as string | number | bigint, + ), + { type: "u64" }, + ), + xdr.ScVal.scvMap([]), + ); + case "release_invoice": + case "refund_invoice": + case "approve_release": + return this.contract.call( + entryPoint, + nativeToScVal( + BigInt((params.invoiceId ?? params.id) as string | number | bigint), + { type: "u64" }, + ), + ); + case "release_invoice_group": + return this.contract.call( + "release_invoice_group", + nativeToScVal(String(params.creator), { type: "address" }), + nativeToScVal(BigInt(params.groupId as string | number | bigint), { + type: "u64", + }), + ); + default: + return this.contract.call( + entryPoint, + ...Object.values(params).map((value) => nativeToScVal(value as never)), + ); + } + } + /** * Pay toward an invoice. * @@ -2913,7 +3278,16 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - The parameters for the method. * @throws {Error} If the method fails. */ - async pay(params: PayParams): Promise { + async pay(params: PayParams & { simulate: true }): Promise; + async pay(params: PayParams): Promise; + async pay(params: PayParams): Promise { + // Issue #844 — `{ simulate: true }` performs a dry-run instead of submitting. + if (params.simulate) { + return this.simulate( + "pay", + params as unknown as Record, + ); + } const startTime = Date.now(); params = this._pluginRegistry.runBeforeCall("pay", params); @@ -4945,7 +5319,21 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - The parameters for the method. * @throws {Error} If the method fails. */ - async releaseGroup(creator: string, groupId: string): Promise { + async releaseGroup( + creator: string, + groupId: string, + options: { simulate: true }, + ): Promise; + async releaseGroup(creator: string, groupId: string): Promise; + async releaseGroup( + creator: string, + groupId: string, + options?: { simulate?: boolean }, + ): Promise { + // Issue #844 — `{ simulate: true }` performs a dry-run instead of submitting. + if (options?.simulate) { + return this.simulate("release_invoice_group", { creator, groupId }); + } const operation = this.contract.call( "release_invoice_group", /** @@ -8277,11 +8665,29 @@ export class StellarSplitClient extends TypedEventEmitter { * @returns The result of the method. * @throws {Error} If the method fails. */ + async refundInvoice( + invoiceId: string, + creator: string, + payerAddress: string | undefined, + options: { simulate: true }, + ): Promise; + async refundInvoice( + invoiceId: string, + creator: string, + payerAddress?: string, + ): Promise<{ txHash: string; fallback: false } | ClaimableRefundResult>; async refundInvoice( invoiceId: string, creator: string, payerAddress?: string, - ): Promise<{ txHash: string; fallback: false } | ClaimableRefundResult> { + options?: { simulate?: boolean }, + ): Promise< + SimulationResult | { txHash: string; fallback: false } | ClaimableRefundResult + > { + // Issue #844 — `{ simulate: true }` performs a dry-run instead of submitting. + if (options?.simulate) { + return this.simulate("refund_invoice", { invoiceId }); + } const startTime = Date.now(); try { diff --git a/src/index.ts b/src/index.ts index f9cb56b..a749984 100644 --- a/src/index.ts +++ b/src/index.ts @@ -586,6 +586,13 @@ export type { Subscription, SubscriptionOptions, SubscriptionLifecycleEvent, + // Issue #844 — dry-run simulation surface + SimulationResult, + LedgerFootprint, + SimulateMutationOptions, + MaybeSimulated, + // Issue #850 — clone field overrides + InvoiceParamOverrides, // New: AMM Calculator PoolSwapEstimate, PoolShareResult, @@ -724,7 +731,10 @@ export { SimulationSandbox } from "./sandbox/SimulationSandbox.js"; export type { SandboxClient, SimulationCost, - SimulationResult, + // The sandbox result type is re-exported under an unambiguous alias so it + // does not collide with the RPC `SimulationResult` (issue #844) exported + // from `./types.js` above. + SimulationResult as SandboxSimulationResult, SandboxInvoiceRecord, SandboxPaymentRecord, SandboxCallLogEntry, diff --git a/src/types.ts b/src/types.ts index b60a6ec..8c36e4a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -486,6 +486,12 @@ export interface CreateInvoiceParams { deadline: number; /** Optional memo / description. */ memo?: string; + /** + * When `true`, simulate the transaction against Soroban RPC instead of + * submitting it, and resolve with a {@link SimulationResult} (issue #844). + * @default false + */ + simulate?: boolean; /** * When `true`, skip the `RecipientBalancePreCheck` that normally runs * before the invoice is submitted. Use only for advanced flows where you @@ -535,6 +541,12 @@ export interface PayParams { * fails to reach its goal. Defaults to false. */ donateOnFailure?: boolean; + /** + * When `true`, simulate the payment against Soroban RPC instead of + * submitting it, and resolve with a {@link SimulationResult} (issue #844). + * @default false + */ + simulate?: boolean; } /** @deprecated Use PayParams instead. */ @@ -787,8 +799,49 @@ export interface CloneOverrides { * recipient account lookups. */ horizonUrl?: string; + /** + * Optional new title/memo stored on the cloned invoice. + * Serialised as the `new_title` entry of the clone override map (issue #850). + */ + newTitle?: string; +} + +/** + * Field-level overrides accepted by {@link StellarSplitClient.cloneInvoice} + * (issue #850). These are mapped onto the contract's `clone_invoice` override + * map after validation, mirroring the checks applied by `createInvoice`. + */ +export interface InvoiceParamOverrides { + /** Optional new title/memo for the cloned invoice (non-empty string). */ + title?: string; + /** Optional new deadline as a future unix timestamp in seconds. */ + deadline?: number; + /** Optional new total target amount in stroops (positive bigint). */ + targetAmount?: bigint; + /** Optional replacement recipient addresses (must be valid Stellar addresses). */ + recipients?: string[]; } +/** + * Options accepted by mutating methods to request a dry-run simulation + * against Soroban RPC instead of submitting a transaction (issue #844). + */ +export interface SimulateMutationOptions { + /** + * When `true`, the transaction is simulated and never submitted, and the + * method resolves with a {@link SimulationResult}. + * @default false + */ + simulate?: boolean; +} + +/** + * Result of a mutating client method that supports `{ simulate: true }`. + * Resolves with the real submission result, or a {@link SimulationResult} + * when simulation was requested. + */ +export type MaybeSimulated = T | SimulationResult; + /** Field names supported by read methods that can return partial objects. */ export type InvoiceField = keyof Invoice; diff --git a/test/clientIssueFixes.test.ts b/test/clientIssueFixes.test.ts new file mode 100644 index 0000000..5d456c8 --- /dev/null +++ b/test/clientIssueFixes.test.ts @@ -0,0 +1,348 @@ +/** + * Tests for GitHub issues #850 (cloneInvoice overrides + getLineage), + * #844 (generic simulate + `{ simulate: true }`), and #842 (subscribeInvoice). + */ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { MockRpcClient } from "../src/testing/mockRpcClient.js"; +import { InvoiceCloneabilityValidator } from "../src/preflight/InvoiceCloneabilityValidator.js"; +import { _resetActiveSubscriptionsForTesting } from "../src/subscription.js"; +import { _resetCursorTrackerForTesting } from "../src/cursorTracker.js"; +import type { Invoice } from "../src/types.js"; + +const CREATOR = Keypair.random().publicKey(); +const RECIPIENT = Keypair.random().publicKey(); +const TOKEN = StrKey.encodeContract(Keypair.random().rawPublicKey()); +const FUTURE_DEADLINE = Math.floor(Date.now() / 1000) + 86_400; + +function makeClient(): StellarSplitClient { + return new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); +} + +function pendingInvoice(overrides: Partial = {}): Invoice { + return { + id: "123", + creator: CREATOR, + recipients: [{ address: RECIPIENT, amount: 1000n }], + token: TOKEN, + deadline: 1_700_000_000, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + } as Invoice; +} + +function injectServer(client: StellarSplitClient, server: unknown): void { + (client as unknown as { _injectedRpcClient: unknown })._injectedRpcClient = + server; +} + +afterEach(() => { + vi.restoreAllMocks(); + _resetActiveSubscriptionsForTesting(); + _resetCursorTrackerForTesting(); +}); + +describe("Issue #850 — getLineage", () => { + it("returns the full ancestor chain ordered root to leaf as bigints", async () => { + const client = makeClient(); + vi.spyOn(client, "getInvoice") + .mockResolvedValueOnce(pendingInvoice({ id: "3" })) + .mockResolvedValueOnce(pendingInvoice({ id: "2" })) + .mockResolvedValueOnce(pendingInvoice({ id: "1" })); + + vi.spyOn( + client as unknown as { _getInvoiceExt: (id: string) => Promise }, + "_getInvoiceExt", + ) + .mockResolvedValueOnce({ parentInvoiceId: "2", cloneDepth: 2 }) + .mockResolvedValueOnce({ parentInvoiceId: "1", cloneDepth: 1 }) + .mockResolvedValueOnce({ parentInvoiceId: null, cloneDepth: 0 }); + + await expect(client.getLineage(3n)).resolves.toEqual([1n, 2n, 3n]); + }); +}); + +describe("Issue #850 — cloneInvoice overrides", () => { + async function setupClone(): Promise<{ + client: StellarSplitClient; + submitSpy: ReturnType; + }> { + const client = makeClient(); + vi.spyOn(client, "getInvoice").mockResolvedValue(pendingInvoice()); + + const { nativeToScVal } = await import("@stellar/stellar-sdk"); + const submitSpy = vi + .spyOn( + client as unknown as { _submitTx: (...args: unknown[]) => unknown }, + "_submitTx", + ) + .mockResolvedValue({ + txHash: "tx-clone", + returnValue: nativeToScVal(456n, { type: "u64" }), + }); + + (client as unknown as { _cache: unknown })._cache = { + get: vi.fn(), + set: vi.fn(), + invalidate: vi.fn(), + clear: vi.fn(), + }; + return { client, submitSpy }; + } + + it("clones with no overrides and returns the new invoice ID", async () => { + const { client, submitSpy } = await setupClone(); + await expect(client.cloneInvoice(123n, { skipValidation: true })).resolves.toBe( + "456", + ); + expect(submitSpy).toHaveBeenCalledTimes(1); + }); + + it("accepts partial field overrides and submits", async () => { + const { client } = await setupClone(); + await expect( + client.cloneInvoice(123n, { + skipValidation: true, + title: "Rebalanced split", + deadline: FUTURE_DEADLINE, + targetAmount: 2_000n, + }), + ).resolves.toBe("456"); + }); + + it("rejects an invalid title override before submission", async () => { + const { client, submitSpy } = await setupClone(); + await expect( + client.cloneInvoice(123n, { skipValidation: true, title: " " }), + ).rejects.toThrow("non-empty string"); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it("rejects a past deadline override before submission", async () => { + const { client, submitSpy } = await setupClone(); + await expect( + client.cloneInvoice(123n, { skipValidation: true, deadline: 1 }), + ).rejects.toThrow("future unix timestamp"); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it("rejects a non-positive target amount override", async () => { + const { client, submitSpy } = await setupClone(); + await expect( + client.cloneInvoice(123n, { skipValidation: true, targetAmount: 0n }), + ).rejects.toThrow("positive bigint"); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it("rejects malformed recipient addresses", async () => { + const { client, submitSpy } = await setupClone(); + await expect( + client.cloneInvoice(123n, { + skipValidation: true, + recipients: ["not-a-stellar-address"], + }), + ).rejects.toThrow("valid Stellar addresses"); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it("throws when the source invoice is not cloneable (terminal)", async () => { + const client = makeClient(); + vi.spyOn(client, "getInvoice").mockResolvedValue( + pendingInvoice({ status: "Released" }), + ); + vi.spyOn(InvoiceCloneabilityValidator.prototype, "validate").mockResolvedValue({ + invoiceId: "123", + cloneable: false, + fieldReports: [ + { field: "status", valid: false, reason: "Invoice is already Released" }, + ], + } as never); + + await expect(client.cloneInvoice(123n)).rejects.toThrow(/not cloneable/i); + }); +}); + +const SIM_SUCCESS = { + result: { retval: undefined }, + events: [], + id: "mock", + latestLedger: 100, + minResourceFee: "1000", + cost: { cpuInsns: "5000", memBytes: "2048" }, +} as never; + +describe("Issue #844 — generic simulate", () => { + it("returns a SimulationResult with fee and resource usage on success", async () => { + const client = makeClient(); + const rpc = new MockRpcClient({ defaultSimulateResponse: SIM_SUCCESS }); + injectServer(client, rpc); + + const result = await client.simulate("create_invoice", { + creator: CREATOR, + recipients: [{ address: RECIPIENT, amount: 1000n }], + token: TOKEN, + deadline: FUTURE_DEADLINE, + }); + + expect(result.success).toBe(true); + expect(result.fee).toBe(1000n); + expect(result.cpuInsns).toBe(5000n); + expect(result.memBytes).toBe(2048n); + expect(result.footprint).toBeDefined(); + expect(rpc.calls.simulate).toHaveLength(1); + }); + + it("maps camelCase method names onto contract entry points", async () => { + const client = makeClient(); + const rpc = new MockRpcClient({ defaultSimulateResponse: SIM_SUCCESS }); + injectServer(client, rpc); + + const result = await client.simulate("refund", { + invoiceId: 7n, + source: CREATOR, + }); + expect(result.success).toBe(true); + expect(rpc.calls.simulate).toHaveLength(1); + }); + + it("returns success:false with the error when the contract rejects the call", async () => { + const client = makeClient(); + const rpc = new MockRpcClient({ + defaultSimulateResponse: { error: "HostError: deadline passed" } as never, + }); + injectServer(client, rpc); + + const result = await client.simulate("pay", { + payer: CREATOR, + invoiceId: "1", + amount: 10n, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("deadline passed"); + expect(result.fee).toBe(0n); + }); +}); + +describe("Issue #844 — { simulate: true } on mutating methods", () => { + it("createInvoice with simulate:true returns a SimulationResult and never submits", async () => { + const client = makeClient(); + const rpc = new MockRpcClient({ defaultSimulateResponse: SIM_SUCCESS }); + injectServer(client, rpc); + + const result = (await client.createInvoice({ + creator: CREATOR, + recipients: [{ address: RECIPIENT, amount: 1000n }], + token: TOKEN, + deadline: FUTURE_DEADLINE, + simulate: true, + })) as { success?: boolean }; + + expect(result.success).toBe(true); + expect(rpc.calls.send).toHaveLength(0); + }); + + it("pay with simulate:true returns a SimulationResult and never submits", async () => { + const client = makeClient(); + const rpc = new MockRpcClient({ defaultSimulateResponse: SIM_SUCCESS }); + injectServer(client, rpc); + + const result = (await client.pay({ + payer: CREATOR, + invoiceId: "1", + amount: 500n, + simulate: true, + })) as { success?: boolean }; + + expect(result.success).toBe(true); + expect(rpc.calls.send).toHaveLength(0); + }); +}); + +const STREAM_EVENTS = [ + { + topic: ["payment", "inv-123"], + value: { payer: "GABC", amount: "1000" }, + ledger: 100, + createdAt: "2026-01-01T00:00:00.000Z", + }, + { + topic: ["released", "inv-123"], + value: { releasedBy: "GXYZ" }, + ledger: 101, + createdAt: "2026-01-01T00:00:01.000Z", + }, +]; + +describe("Issue #842 — subscribeInvoice", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("streams events for an invoice and stops polling on unsubscribe", async () => { + const client = makeClient(); + const rpc = new MockRpcClient({ + defaultGetEventsResponse: { + events: STREAM_EVENTS, + latestLedger: 105, + } as never, + defaultGetLatestLedgerResponse: { + id: "mock", + sequence: 100, + protocolVersion: 21, + } as never, + }); + injectServer(client, rpc); + + const received: string[] = []; + const subscription = client.subscribeInvoice( + "inv-123", + (event) => received.push(event.type), + { pollIntervalMs: 100 }, + ); + + expect(subscription.getInvoiceId()).toBe("inv-123"); + expect(subscription.isActive()).toBe(true); + + await vi.advanceTimersByTimeAsync(0); + expect(received).toEqual(["payment", "released"]); + + subscription.unsubscribe(); + const pollCount = rpc.calls.getEvents.length; + await vi.advanceTimersByTimeAsync(500); + + expect(subscription.isActive()).toBe(false); + expect(rpc.calls.getEvents.length).toBe(pollCount); + }); + + it("accepts a bigint invoice ID", () => { + const client = makeClient(); + injectServer( + client, + new MockRpcClient({ + defaultGetEventsResponse: { events: [], latestLedger: 1 } as never, + defaultGetLatestLedgerResponse: { + id: "mock", + sequence: 1, + protocolVersion: 21, + } as never, + }), + ); + + const subscription = client.subscribeInvoice(42n, vi.fn()); + expect(subscription.getInvoiceId()).toBe("42"); + subscription.unsubscribe(); + }); +}); +