Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions soroban-client/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,7 +85,7 @@ const result = await sdk.eventManager.createEvent(
{
source: walletAddress,
signTransaction,
}
},
);
```

Expand All @@ -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
Expand All @@ -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);
Expand All @@ -99,13 +138,15 @@ const decodeCustom = decodeStruct({
```

**Key Features:**

- Type-safe contract response parsing
- Composable decoders for complex structures
- Clear error messages with context
- Built-in Soroban type support (u32, u64, u128, i128, etc.)
- 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
Expand Down Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions soroban-client/sdk/src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => fn(),
);
(core as any).rpcServer.simulateTransaction = jest.fn().mockResolvedValue({
result: { retval: nativeToScVal(12, { type: "u32" }) },
});

const value = await core.read<number>("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<unknown>) => 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();
});
});
Loading
Loading