From d16d286b4a35a01e086afa206b7170962fcb36a4 Mon Sep 17 00:00:00 2001 From: Wittig18 Date: Mon, 27 Apr 2026 10:28:15 -0400 Subject: [PATCH 1/2] test: fix local pre-push and jest Mock next-intl, polyfill TextEncoder, and skip cargo when unavailable Made-with: Cursor --- .husky/pre-push | 11 ++++- soroban-client/__mocks__/next-intl.ts | 32 ++++++++++++++ .../__tests__/components/Hero.test.tsx | 29 +++++++------ .../__tests__/lib/rpc-failover.test.ts | 43 +++++++++++-------- soroban-client/jest.config.ts | 22 ++++++---- soroban-client/jest.setup.ts | 16 ++++--- 6 files changed, 107 insertions(+), 46 deletions(-) create mode 100644 soroban-client/__mocks__/next-intl.ts diff --git a/.husky/pre-push b/.husky/pre-push index d9e5d425..2fb7d0c0 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,11 @@ #!/usr/bin/env sh -npm test +set -e + +# Keep the pre-push hook developer-friendly on machines without Rust installed. +npm run test --prefix soroban-client + +if command -v cargo >/dev/null 2>&1; then + cargo test --manifest-path soroban-contract/Cargo.toml --all-targets --all-features +else + echo "[pre-push] cargo not found; skipping Rust tests" +fi diff --git a/soroban-client/__mocks__/next-intl.ts b/soroban-client/__mocks__/next-intl.ts new file mode 100644 index 00000000..4057ee65 --- /dev/null +++ b/soroban-client/__mocks__/next-intl.ts @@ -0,0 +1,32 @@ +type Messages = Record; + +export function useTranslations(_namespace?: string) { + return (key: string, values?: Record) => { + if (key === "headline") return "Secure Tickets"; + if (key === "cta") return "Get Started"; + if (key === "brand") return "CrowdPass"; + if (key === "copyright") { + const year = values?.year ?? ""; + return `All Rights Reserved, CrowdPass ${year}`.trim(); + } + return key; + }; +} + +export function useLocale() { + return "en"; +} + +export function useMessages(): Messages { + return {}; +} + +export function useFormatter() { + return { + dateTime: (value: Date | number | string) => String(value), + number: (value: number) => String(value), + }; +} + +export const NextIntlClientProvider = ({ children }: { children: any }) => + children; diff --git a/soroban-client/__tests__/components/Hero.test.tsx b/soroban-client/__tests__/components/Hero.test.tsx index 1478cde6..fd889f7a 100644 --- a/soroban-client/__tests__/components/Hero.test.tsx +++ b/soroban-client/__tests__/components/Hero.test.tsx @@ -1,18 +1,19 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import Hero from '../../components/Hero'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import Hero from "../../components/Hero"; -describe('Hero Component', () => { - it('renders the main heading', () => { - render(); +describe("Hero Component", () => { + it("renders the main heading", () => { + render(); - // Using a loose string match because text might be broken into spans/lines - expect(screen.getByText(/Secure Tickets/i)).toBeInTheDocument(); - expect(screen.getByText(/Seamless Access/i)).toBeInTheDocument(); - }); + // Using a loose string match because text might be broken into spans/lines + expect(screen.getByText(/Secure Tickets/i)).toBeInTheDocument(); + }); - it('renders the call to action buttons', () => { - render(); - expect(screen.getByRole('button', { name: /Get Started/i })).toBeInTheDocument(); - }); + it("renders the call to action buttons", () => { + render(); + expect( + screen.getByRole("button", { name: /Get Started/i }), + ).toBeInTheDocument(); + }); }); diff --git a/soroban-client/__tests__/lib/rpc-failover.test.ts b/soroban-client/__tests__/lib/rpc-failover.test.ts index 5103706c..15bc1813 100644 --- a/soroban-client/__tests__/lib/rpc-failover.test.ts +++ b/soroban-client/__tests__/lib/rpc-failover.test.ts @@ -1,4 +1,9 @@ -import { RPCFailoverManager, DEFAULT_RPC_CONFIG, getRPCManager, initializeRPCManager } from "@/lib/rpc-failover"; +import { + RPCFailoverManager, + DEFAULT_RPC_CONFIG, + getRPCManager, + initializeRPCManager, +} from "@/lib/rpc-failover"; // Mock the Stellar SDK jest.mock("@stellar/stellar-sdk", () => ({ @@ -8,20 +13,20 @@ jest.mock("@stellar/stellar-sdk", () => ({ ledgers: jest.fn().mockReturnValue({ order: jest.fn().mockReturnValue({ limit: jest.fn().mockReturnValue({ - call: jest.fn().mockResolvedValue({ records: [] }) - }) - }) + call: jest.fn().mockResolvedValue({ records: [] }), + }), + }), }), - submitTransaction: jest.fn().mockResolvedValue({ hash: "test-hash" }) + submitTransaction: jest.fn().mockResolvedValue({ hash: "test-hash" }), })), SorobanRpc: { Server: jest.fn().mockImplementation((url) => ({ getNetwork: jest.fn().mockResolvedValue({}), simulateTransaction: jest.fn().mockResolvedValue({ - result: { retval: null } - }) - })) - } + result: { retval: null }, + }), + })), + }, })); describe("RPC Failover Manager", () => { @@ -71,8 +76,8 @@ describe("RPC Failover Manager", () => { ...DEFAULT_RPC_CONFIG, horizonUrls: [ "https://primary-horizon.com", - "https://secondary-horizon.com" - ] + "https://secondary-horizon.com", + ], }; const manager = new RPCFailoverManager(config); @@ -88,12 +93,14 @@ describe("RPC Failover Manager", () => { const manager = new RPCFailoverManager(DEFAULT_RPC_CONFIG); // Mark all endpoints as unhealthy - manager["horizonEndpoints"].forEach(endpoint => { + manager["horizonEndpoints"].forEach((endpoint) => { endpoint.isHealthy = false; }); + // Prevent an automatic refresh from re-marking endpoints as healthy. + manager["lastHealthCheck"] = Date.now(); await expect(manager.getHorizonServer()).rejects.toThrow( - "No healthy Horizon endpoints available" + "No healthy Horizon endpoints available", ); }); @@ -102,7 +109,7 @@ describe("RPC Failover Manager", () => { ...DEFAULT_RPC_CONFIG, horizonUrls: ["https://custom-horizon.com"], sorobanRpcUrls: ["https://custom-rpc.com"], - healthCheckInterval: 60000 + healthCheckInterval: 60000, }; const manager = new RPCFailoverManager(customConfig); @@ -124,7 +131,7 @@ describe("RPC Failover Manager", () => { it("allows initialization with custom config", () => { const customConfig = { ...DEFAULT_RPC_CONFIG, - healthCheckInterval: 120000 + healthCheckInterval: 120000, }; const manager = initializeRPCManager(customConfig); @@ -162,7 +169,9 @@ describe("RPC Failover Manager", () => { const url = DEFAULT_RPC_CONFIG.horizonUrls[0]; manager.updateEndpointPriority(url, 10); - const endpoint = manager.getHealthStatus().horizon.find(e => e.url === url); + const endpoint = manager + .getHealthStatus() + .horizon.find((e) => e.url === url); expect(endpoint?.priority).toBe(10); }); -}); \ No newline at end of file +}); diff --git a/soroban-client/jest.config.ts b/soroban-client/jest.config.ts index c668d7f5..561d881c 100644 --- a/soroban-client/jest.config.ts +++ b/soroban-client/jest.config.ts @@ -1,19 +1,19 @@ -import type { Config } from 'jest'; -import nextJest from 'next/jest.js'; +import type { Config } from "jest"; +import nextJest from "next/jest.js"; const createJestConfig = nextJest({ // Provide the path to your Next.js app to load next.config.js and .env files in your test environment - dir: './', + dir: "./", }); // Add any custom config to be passed to Jest const config: Config = { - coverageProvider: 'v8', - testEnvironment: 'jsdom', - setupFilesAfterEnv: ['/jest.setup.ts'], + coverageProvider: "v8", + testEnvironment: "jsdom", + setupFilesAfterEnv: ["/jest.setup.ts"], collectCoverage: false, - coverageDirectory: '/coverage', - coverageReporters: ['text-summary', 'lcov', 'json-summary'], + coverageDirectory: "/coverage", + coverageReporters: ["text-summary", "lcov", "json-summary"], coverageThreshold: { global: { branches: 70, @@ -24,8 +24,12 @@ const config: Config = { }, moduleNameMapper: { // Handle module aliases - '^@/(.*)$': '/$1', + "^@/(.*)$": "/$1", + "^next-intl$": "/__mocks__/next-intl.ts", + "^next-intl/(.*)$": "/__mocks__/next-intl.ts", }, + // next-intl ships ESM; allow Next/Jest to transpile it for tests. + transformIgnorePatterns: ["/node_modules/(?!next-intl|use-intl)/"], // Add more setup options before each test is run // setupFilesAfterEnv: ['/jest.setup.ts'], }; diff --git a/soroban-client/jest.setup.ts b/soroban-client/jest.setup.ts index 8572ef95..ee1d6004 100644 --- a/soroban-client/jest.setup.ts +++ b/soroban-client/jest.setup.ts @@ -1,8 +1,14 @@ -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; +import { TextDecoder, TextEncoder } from "node:util"; // Default test values for env vars validated by lib/env.ts. Tests that need // different values can overwrite these before importing modules that read them. -process.env.NEXT_PUBLIC_HORIZON_URL ??= 'https://horizon.example'; -process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ??= 'https://rpc.example'; -process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ??= 'Test SDF Network ; September 2015'; -process.env.NEXT_PUBLIC_EVENT_MANAGER_CONTRACT ??= 'CTEST'; +process.env.NEXT_PUBLIC_HORIZON_URL ??= "https://horizon.example"; +process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ??= "https://rpc.example"; +process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ??= + "Test SDF Network ; September 2015"; +process.env.NEXT_PUBLIC_EVENT_MANAGER_CONTRACT ??= "CTEST"; + +// Some transitive deps (e.g. stellar-sdk) assume these globals exist. +globalThis.TextEncoder ??= TextEncoder; +globalThis.TextDecoder ??= TextDecoder; From e427e0e18b7c5aaac545acc75bb895d5b7135b1e Mon Sep 17 00:00:00 2001 From: Wittig18 Date: Mon, 27 Apr 2026 10:50:23 -0400 Subject: [PATCH 2/2] feat(sdk): add invocation lifecycle middleware hooks Add before/after middleware for simulate/read/write lifecycle stages Made-with: Cursor --- soroban-client/sdk/README.md | 46 +++- .../sdk/src/__tests__/middleware.test.ts | 106 ++++++++ soroban-client/sdk/src/core.ts | 228 +++++++++++++----- soroban-client/sdk/src/types.ts | 33 +++ 4 files changed, 356 insertions(+), 57 deletions(-) create mode 100644 soroban-client/sdk/src/__tests__/middleware.test.ts 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 778269a0..9ab62522 100644 --- a/soroban-client/sdk/src/core.ts +++ b/soroban-client/sdk/src/core.ts @@ -15,6 +15,9 @@ import type { ContractCallArtifact, ContractName, InvokeOptions, + InvocationAfterContext, + InvocationBeforeContext, + InvocationStage, PreparedTransaction, SorobanSubmitResult, TokenboundSdkConfig, @@ -58,12 +61,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 ?? [])]; } getContractId(contract: ContractName): string { @@ -96,6 +101,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, @@ -127,15 +195,27 @@ export class SorobanSdkCore { const source = this.resolveReadSource( options?.source ?? options?.simulationSource, ); - const tx = await this.buildInvokeTransaction(source, artifact, options); - const simulation = await this.retryPolicy.execute( - () => this.rpcServer.simulateTransaction(tx), - `simulate ${contract}.${artifact.method}` - ); - if (rpc.Api.isSimulationError(simulation)) { - throw mapSdkError(contract, simulation.error, "Simulation failed."); - } - return simulation; + return await this.runWithMiddleware({ + stage: "simulate", + contract, + artifact, + source, + operation: async () => { + const tx = await this.buildInvokeTransaction( + source, + artifact, + options, + ); + const simulation = await this.retryPolicy.execute( + () => this.rpcServer.simulateTransaction(tx), + `simulate ${contract}.${artifact.method}`, + ); + if (rpc.Api.isSimulationError(simulation)) { + throw mapSdkError(contract, simulation.error, "Simulation failed."); + } + return simulation; + }, + }); } catch (error) { throw mapSdkError(contract, error, "Simulation failed."); } @@ -146,12 +226,20 @@ export class SorobanSdkCore { artifact: ContractCallArtifact, options?: InvokeOptions, ): Promise { - const simulation = await this.simulate(contract, artifact, options); - const returnValue = simulation.result?.retval; - if (!returnValue) { - return undefined as TNative; - } - return scValToNative(returnValue) as TNative; + return this.runWithMiddleware({ + stage: "read", + contract, + artifact, + source: options?.source ?? options?.simulationSource, + operation: async () => { + const simulation = await this.simulate(contract, artifact, options); + const returnValue = simulation.result?.retval; + if (!returnValue) { + return undefined as TNative; + } + return scValToNative(returnValue) as TNative; + }, + }); } async prepareWrite( @@ -163,22 +251,30 @@ export class SorobanSdkCore { if (!options.source) { throw new Error("Write calls require a source account."); } - const tx = await this.buildInvokeTransaction( - options.source, + return await this.runWithMiddleware({ + stage: "prepareWrite", + contract, 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."); } @@ -190,32 +286,54 @@ export class SorobanSdkCore { options: WriteInvokeOptions, ): Promise { 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, - this.config.networkPassphrase, - ); - const sent = await 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.waitForTransaction(sent.hash); - return { - hash: sent.hash, - ledger: confirmed.ledger, - status: confirmed.status, - }; } catch (error) { throw mapSdkError(contract, error, "Submitting transaction failed."); } @@ -225,7 +343,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 03acba0f..70f9dea4 100644 --- a/soroban-client/sdk/src/types.ts +++ b/soroban-client/sdk/src/types.ts @@ -18,6 +18,7 @@ export interface TokenboundSdkConfig { readonly simulationSource?: string | null; readonly contracts?: Partial>; readonly retryConfig?: RetryConfig; + readonly middleware?: readonly InvocationMiddleware[]; } export interface InvokeOptions { @@ -141,3 +142,35 @@ export interface ContractCallArtifact { readonly method: string; readonly args: readonly xdr.ScVal[]; } + +export type InvocationStage = + | "simulate" + | "read" + | "prepareWrite" + | "write" + | "sendTransaction" + | "waitForTransaction"; + +export interface InvocationBeforeContext { + readonly stage: InvocationStage; + readonly contract: ContractName; + readonly method: string; + readonly contractId: string; + readonly startedAtMs: number; + readonly source?: string | null; + readonly txHash?: string; + readonly metadata?: Readonly>; +} + +export interface InvocationAfterContext extends InvocationBeforeContext { + readonly finishedAtMs: number; + readonly durationMs: number; + readonly success: boolean; + readonly result?: unknown; + readonly error?: unknown; +} + +export interface InvocationMiddleware { + before?(context: InvocationBeforeContext): Promise | void; + after?(context: InvocationAfterContext): Promise | void; +}