diff --git a/soroban-client/sdk/README.md b/soroban-client/sdk/README.md index 6e0ce62b..48daf9af 100644 --- a/soroban-client/sdk/README.md +++ b/soroban-client/sdk/README.md @@ -37,6 +37,37 @@ const sdk = createTokenboundSdk({ const events = await sdk.eventManager.getAllEvents(); ``` +### Invocation middleware hooks + +You can attach middleware to run logic before and after each invocation lifecycle stage +(`simulate`, `read`, `prepareWrite`, `write`, `sendTransaction`, `waitForTransaction`). +This is useful for request signing policies, logging, tracing, and metrics. + +```ts +const sdk = createTokenboundSdk({ + horizonUrl: process.env.NEXT_PUBLIC_HORIZON_URL!, + sorobanRpcUrl: process.env.NEXT_PUBLIC_SOROBAN_RPC_URL!, + networkPassphrase: process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE!, + contracts: { + eventManager: process.env.NEXT_PUBLIC_EVENT_MANAGER_CONTRACT, + }, + middleware: [ + { + before: ({ stage, contract, method }) => { + console.log(`[before] ${stage} ${contract}.${method}`); + }, + after: ({ stage, success, durationMs, error }) => { + if (!success) { + console.error(`[after] ${stage} failed in ${durationMs}ms`, error); + return; + } + console.log(`[after] ${stage} success in ${durationMs}ms`); + }, + }, + ], +}); +``` + ### Creating an event ```ts @@ -54,7 +85,7 @@ const result = await sdk.eventManager.createEvent( { source: walletAddress, signTransaction, - } + }, ); ``` @@ -63,6 +94,7 @@ const result = await sdk.eventManager.createEvent( The SDK automatically retries failed RPC calls with exponential backoff and jitter. This handles transient network failures, rate limiting, and temporary service unavailability. **Key Features:** + - Automatic retries for transient errors (network issues, timeouts, 5xx errors) - Exponential backoff with configurable parameters - Jitter to prevent thundering herd problems @@ -82,7 +114,14 @@ npm run sdk:generate-types The SDK provides typed decoder utilities for safely parsing contract responses: ```ts -import { ContractDecoder, decodeArray, decodeStruct, decodeU32, decodeString, decodeI128 } from "./src"; +import { + ContractDecoder, + decodeArray, + decodeStruct, + decodeU32, + decodeString, + decodeI128, +} from "./src"; // Decode event response const event = ContractDecoder.event()(rawResponse); @@ -99,6 +138,7 @@ const decodeCustom = decodeStruct({ ``` **Key Features:** + - Type-safe contract response parsing - Composable decoders for complex structures - Clear error messages with context @@ -106,6 +146,7 @@ const decodeCustom = decodeStruct({ - Pre-built decoders for contract types See [DECODERS.md](./DECODERS.md) for detailed documentation. + ### Batch ledger-entry fetch `batchGetLedgerEntries` wraps `rpc.Server.getLedgerEntries` so callers can @@ -134,6 +175,7 @@ for (let i = 0; i < ledgerKeys.length; i += 1) { ``` `chunkSize`, `concurrency`, and `keyId` are all overridable. + ### Caching contract schemas at runtime Soroban contracts in this repo follow the upgradeable pattern, so each diff --git a/soroban-client/sdk/src/__tests__/middleware.test.ts b/soroban-client/sdk/src/__tests__/middleware.test.ts new file mode 100644 index 00000000..1840b81c --- /dev/null +++ b/soroban-client/sdk/src/__tests__/middleware.test.ts @@ -0,0 +1,106 @@ +import { nativeToScVal } from "@stellar/stellar-base"; +import { TransactionBuilder } from "@stellar/stellar-sdk"; + +import { SorobanSdkCore } from "../core"; +import type { InvocationAfterContext, InvocationBeforeContext } from "../types"; + +describe("invocation middleware", () => { + it("runs before/after hooks for read and simulate lifecycle", async () => { + const before: InvocationBeforeContext[] = []; + const after: InvocationAfterContext[] = []; + + const core = new SorobanSdkCore({ + horizonUrl: "https://horizon-testnet.stellar.org", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + simulationSource: + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + middleware: [ + { + before: (ctx) => before.push(ctx), + after: (ctx) => after.push(ctx), + }, + ], + }); + + const artifact = { + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + method: "get_event_count", + args: [], + }; + + (core as any).buildInvokeTransaction = jest.fn().mockResolvedValue({}); + (core as any).retryPolicy.execute = jest.fn( + async (fn: () => Promise) => fn(), + ); + (core as any).rpcServer.simulateTransaction = jest.fn().mockResolvedValue({ + result: { retval: nativeToScVal(12, { type: "u32" }) }, + }); + + const value = await core.read("eventManager", artifact, {}); + + expect(value).toBe(12); + expect(before.map((ctx) => ctx.stage)).toEqual(["read", "simulate"]); + expect(after.map((ctx) => ctx.stage)).toEqual(["simulate", "read"]); + expect(after.every((ctx) => ctx.success)).toBe(true); + }); + + it("runs write/send/wait hooks and captures errors", async () => { + const before: InvocationBeforeContext[] = []; + const after: InvocationAfterContext[] = []; + + const core = new SorobanSdkCore({ + horizonUrl: "https://horizon-testnet.stellar.org", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + middleware: [ + { + before: (ctx) => before.push(ctx), + after: (ctx) => after.push(ctx), + }, + ], + }); + + const artifact = { + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + method: "purchase_ticket", + args: [], + }; + + (core as any).prepareWrite = jest.fn().mockResolvedValue({ + xdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + source: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + }); + const fromXdrSpy = jest + .spyOn(TransactionBuilder, "fromXDR") + .mockReturnValue({} as never); + (core as any).rpcServer.sendTransaction = jest + .fn() + .mockResolvedValue({ status: "PENDING", hash: "abc123" }); + (core as any).retryPolicy.execute = jest.fn( + async (fn: () => Promise) => fn(), + ); + (core as any).waitForTransaction = jest + .fn() + .mockRejectedValue(new Error("boom from confirmation")); + + await expect( + core.write("eventManager", artifact, { + source: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + signTransaction: async () => "AAAA", + }), + ).rejects.toThrow("boom from confirmation"); + + fromXdrSpy.mockRestore(); + + expect(before.map((ctx) => ctx.stage)).toEqual([ + "write", + "sendTransaction", + "waitForTransaction", + ]); + const writeAfter = after.find((ctx) => ctx.stage === "write"); + expect(writeAfter?.success).toBe(false); + expect(writeAfter?.error).toBeDefined(); + }); +}); diff --git a/soroban-client/sdk/src/core.ts b/soroban-client/sdk/src/core.ts index 72f26298..b3273ab9 100644 --- a/soroban-client/sdk/src/core.ts +++ b/soroban-client/sdk/src/core.ts @@ -16,6 +16,9 @@ import type { ContractCallArtifact, ContractName, InvokeOptions, + InvocationAfterContext, + InvocationBeforeContext, + InvocationStage, PreparedTransaction, SorobanSubmitResult, TokenboundSdkConfig, @@ -60,12 +63,14 @@ export class SorobanSdkCore { readonly horizonServer: Horizon.Server; readonly rpcServer: rpc.Server; readonly retryPolicy: RetryPolicy; + private readonly middleware; constructor(config: TokenboundSdkConfig) { this.config = config; this.horizonServer = new Horizon.Server(config.horizonUrl); this.rpcServer = new rpc.Server(config.sorobanRpcUrl); this.retryPolicy = new RetryPolicy(config.retryConfig); + this.middleware = [...(config.middleware ?? [])]; } // ── Tracing helpers ───────────────────────────────────────────────────────── @@ -136,6 +141,69 @@ export class SorobanSdkCore { return source; } + private async runWithMiddleware({ + stage, + contract, + artifact, + source, + txHash, + metadata, + operation, + }: { + stage: InvocationStage; + contract: ContractName; + artifact: ContractCallArtifact; + source?: string | null; + txHash?: string; + metadata?: Readonly>; + operation: () => Promise; + }): Promise { + const startedAtMs = Date.now(); + const base: InvocationBeforeContext = { + stage, + contract, + method: artifact.method, + contractId: artifact.contractId, + startedAtMs, + source, + txHash, + metadata, + }; + + for (const hook of this.middleware) { + await hook.before?.(base); + } + + try { + const result = await operation(); + const finishedAtMs = Date.now(); + const context: InvocationAfterContext = { + ...base, + finishedAtMs, + durationMs: finishedAtMs - startedAtMs, + success: true, + result, + }; + for (const hook of this.middleware) { + await hook.after?.(context); + } + return result; + } catch (error) { + const finishedAtMs = Date.now(); + const context: InvocationAfterContext = { + ...base, + finishedAtMs, + durationMs: finishedAtMs - startedAtMs, + success: false, + error, + }; + for (const hook of this.middleware) { + await hook.after?.(context); + } + throw error; + } + } + async buildInvokeTransaction( source: string, artifact: ContractCallArtifact, @@ -261,19 +329,26 @@ export class SorobanSdkCore { const tx = await this.buildInvokeTransaction( options.source, artifact, - options, - ); - const simulation = await this.rpcServer.simulateTransaction(tx); - if (rpc.Api.isSimulationError(simulation)) { - throw mapSdkError(contract, simulation.error, "Simulation failed."); - } - // Prepared write transactions are assembled from a successful simulation. - const prepared = rpc.assembleTransaction(tx, simulation).build(); - return { - xdr: prepared.toXDR(), - networkPassphrase: this.config.networkPassphrase, source: options.source, - }; + operation: async () => { + const tx = await this.buildInvokeTransaction( + options.source!, + artifact, + options, + ); + const simulation = await this.rpcServer.simulateTransaction(tx); + if (rpc.Api.isSimulationError(simulation)) { + throw mapSdkError(contract, simulation.error, "Simulation failed."); + } + // Prepared write transactions are assembled from a successful simulation. + const prepared = rpc.assembleTransaction(tx, simulation).build(); + return { + xdr: prepared.toXDR(), + networkPassphrase: this.config.networkPassphrase, + source: options.source!, + }; + }, + }); } catch (error) { throw mapSdkError(contract, error, "Preparing transaction failed."); } @@ -319,10 +394,53 @@ export class SorobanSdkCore { throw mapSdkError(contract, error, "Submitting transaction failed."); } try { - const prepared = await this.prepareWrite(contract, artifact, options); - const signedXdr = await options.signTransaction(prepared.xdr, { - networkPassphrase: prepared.networkPassphrase, - address: prepared.source, + return await this.runWithMiddleware({ + stage: "write", + contract, + artifact, + source: options.source, + operation: async () => { + const prepared = await this.prepareWrite(contract, artifact, options); + const signedXdr = await options.signTransaction(prepared.xdr, { + networkPassphrase: prepared.networkPassphrase, + address: prepared.source, + }); + const signedTx = TransactionBuilder.fromXDR( + signedXdr, + this.config.networkPassphrase, + ); + const sent = await this.runWithMiddleware({ + stage: "sendTransaction", + contract, + artifact, + source: options.source, + operation: async () => + this.retryPolicy.execute( + () => this.rpcServer.sendTransaction(signedTx), + `sendTransaction ${contract}.${artifact.method}`, + ), + }); + if (sent.status === "ERROR") { + throw new Error( + sent.errorResult + ? String(sent.errorResult) + : "Transaction submission failed.", + ); + } + const confirmed = await this.runWithMiddleware({ + stage: "waitForTransaction", + contract, + artifact, + source: options.source, + txHash: sent.hash, + operation: async () => this.waitForTransaction(sent.hash), + }); + return { + hash: sent.hash, + ledger: confirmed.ledger, + status: confirmed.status, + }; + }, }); const signedTx = TransactionBuilder.fromXDR( signedXdr, @@ -346,7 +464,7 @@ export class SorobanSdkCore { for (let attempt = 0; attempt < attempts; attempt += 1) { const transaction = await this.retryPolicy.execute( () => this.rpcServer.getTransaction(hash), - `getTransaction ${hash}` + `getTransaction ${hash}`, ); if (transaction.status === rpc.Api.GetTransactionStatus.SUCCESS) { return transaction; diff --git a/soroban-client/sdk/src/types.ts b/soroban-client/sdk/src/types.ts index c882fdf3..01fb93a7 100644 --- a/soroban-client/sdk/src/types.ts +++ b/soroban-client/sdk/src/types.ts @@ -20,6 +20,7 @@ export interface TokenboundSdkConfig { /** Optional tracing hooks for observability. */ readonly tracing?: TracingConfig; readonly retryConfig?: RetryConfig; + readonly middleware?: readonly InvocationMiddleware[]; } export interface InvokeOptions { @@ -198,4 +199,4 @@ export interface TracingConfig { * If false (default) the caller must supply one via `InvokeOptions`. */ readonly autoCorrelation?: boolean; -} \ No newline at end of file +}