diff --git a/README.md b/README.md index 9f41eafc..92ad663e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ multiple operations into a single programmable and extensible toolkit. - Liquidity pool (LP) deposits & withdrawals - Querying pool reserves and share IDs - Custom contract integrations (current) +- Claimable balances for conditional / time-locked payments (escrow & vesting) - Designed for future LP provider integrations - Supports Testnet & Mainnet @@ -331,6 +332,58 @@ const shareId = await agent.lp.getShareId(); --- +## 🔒 Claimable Balances (Conditional / Time-Locked Payments) + +Stellar's native primitive for **escrow, vesting, and scheduled payouts** — +ideal for AI-agent workflows where funds must be released only when a +predicate is satisfied. + +```typescript +import { AgentClient } from "stellartools"; + +const agent = new AgentClient({ network: "testnet" }); + +// 1. Lock 100 XLM for `recipient`, claimable any time within the next 24h +const created = await agent.claimable.create({ + sourceSecret: process.env.SOURCE_SECRET!, + asset: { code: "XLM" }, + amount: "100", + claimants: [ + { + destination: "GRECIPIENT...", + predicate: { type: "beforeRelativeTime", seconds: 86400 }, + }, + ], +}); +console.log(created.transactionHash, created.balanceIds); + +// 2. List claimable balances awaiting a specific account +const open = await agent.claimable.list({ claimant: "GRECIPIENT..." }); + +// 3. Claim a balance +await agent.claimable.claim({ + claimerSecret: process.env.RECIPIENT_SECRET!, + balanceId: created.balanceIds[0], +}); +``` + +**Supported predicates** (composable via `and` / `or` / `not`): + +| Type | Meaning | +| --- | --- | +| `unconditional` | Always claimable | +| `beforeRelativeTime` | Claimable for N seconds after creation | +| `beforeAbsoluteTime` | Claimable until a Unix epoch timestamp | +| `not` | Negation of an inner predicate | +| `and` / `or` | Logical combination of two inner predicates | + +Each input claimant becomes its own `CreateClaimableBalance` operation, so the +returned `balanceIds` map 1:1 with `claimants`. Up to **100 operations per +transaction** (Stellar protocol cap on operations per tx) — split larger batches +across multiple calls. + +--- + ## 🌐 Supported Networks - **Testnet** - Full support, no restrictions, safe for development diff --git a/agent.ts b/agent.ts index 36d4d8c3..0d4dc89b 100644 --- a/agent.ts +++ b/agent.ts @@ -15,6 +15,19 @@ import { type SwapBestRouteResult, } from "./lib/dex"; import { bridgeTokenTool } from "./tools/bridge"; +import { + createClaimableBalance as cbCreate, + claimClaimableBalance as cbClaim, + listClaimableBalances as cbList, + type CreateClaimableBalanceParams, + type CreateClaimableBalanceResult, + type ClaimClaimableBalanceParams, + type ClaimClaimableBalanceResult, + type ListClaimableBalancesParams, + type ClaimableBalanceRecord, + type ClaimPredicate, + type ClaimantInput, +} from "./lib/claimableBalance"; import { Horizon, Keypair, @@ -66,6 +79,14 @@ export type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, + CreateClaimableBalanceParams, + CreateClaimableBalanceResult, + ClaimClaimableBalanceParams, + ClaimClaimableBalanceResult, + ListClaimableBalancesParams, + ClaimableBalanceRecord, + ClaimPredicate, + ClaimantInput, }; export class AgentClient { @@ -238,6 +259,52 @@ export class AgentClient { }, }; + /** + * Claimable Balances — conditional / time-locked payments (escrow, vesting, + * scheduled payouts). Useful for AI-agent workflows where funds must be + * released only when a predicate evaluates to true. + * + * @example + * // Lock 100 XLM for `recipient`, claimable after 24h + * await agent.claimable.create({ + * sourceSecret: process.env.SECRET!, + * asset: { code: "XLM" }, + * amount: "100", + * claimants: [{ + * destination: recipient, + * predicate: { type: "beforeRelativeTime", seconds: 86400 }, + * }], + * }); + */ + public claimable = { + create: async ( + params: CreateClaimableBalanceParams + ): Promise => { + return await cbCreate( + { network: this.network, horizonUrl: this.rpcUrl }, + params + ); + }, + + claim: async ( + params: ClaimClaimableBalanceParams + ): Promise => { + return await cbClaim( + { network: this.network, horizonUrl: this.rpcUrl }, + params + ); + }, + + list: async ( + params?: ListClaimableBalancesParams + ): Promise => { + return await cbList( + { network: this.network, horizonUrl: this.rpcUrl }, + params ?? {} + ); + }, + }; + /** * Launch a new token on the Stellar network. * diff --git a/index.ts b/index.ts index 42c43091..cfdfca95 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,14 @@ import type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, + CreateClaimableBalanceParams, + CreateClaimableBalanceResult, + ClaimClaimableBalanceParams, + ClaimClaimableBalanceResult, + ListClaimableBalancesParams, + ClaimableBalanceRecord, + ClaimPredicate, + ClaimantInput, } from "./agent"; export { @@ -30,6 +38,14 @@ export type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, + CreateClaimableBalanceParams, + CreateClaimableBalanceResult, + ClaimClaimableBalanceParams, + ClaimClaimableBalanceResult, + ListClaimableBalancesParams, + ClaimableBalanceRecord, + ClaimPredicate, + ClaimantInput, }; export const stellarTools = [ bridgeTokenTool, diff --git a/lib/claimableBalance.ts b/lib/claimableBalance.ts new file mode 100644 index 00000000..00321b32 --- /dev/null +++ b/lib/claimableBalance.ts @@ -0,0 +1,368 @@ +import { + Asset, + BASE_FEE, + Claimant, + Horizon, + Keypair, + Networks, + Operation, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; + +/** + * Claimable Balance support for Stellar AgentKit. + * + * Claimable Balances are a unique Stellar primitive that enable conditional + * and time-locked payments (escrow, vesting, scheduled payouts). They are + * particularly useful for AI-agent workflows where funds must be released + * upon predicates (e.g. "after 24h", "before deadline", "on demand"). + * + * @see https://developers.stellar.org/docs/learn/encyclopedia/transactions-specialized/claimable-balances + */ + +export type NetworkName = "testnet" | "mainnet"; + +export interface ClaimableBalanceContext { + network: NetworkName; + horizonUrl: string; +} + +/** + * High-level predicate definition. Mirrors the on-chain semantics but is + * expressed as a friendly tagged union for ergonomic use from agent code. + */ +export type ClaimPredicate = + | { type: "unconditional" } + | { type: "beforeRelativeTime"; seconds: number | string } + | { type: "beforeAbsoluteTime"; epochSeconds: number | string } + | { type: "not"; predicate: ClaimPredicate } + | { type: "and"; predicates: [ClaimPredicate, ClaimPredicate] } + | { type: "or"; predicates: [ClaimPredicate, ClaimPredicate] }; + +export interface ClaimantInput { + destination: string; + predicate?: ClaimPredicate; +} + +export interface AssetInput { + code: string; + issuer?: string; +} + +export interface CreateClaimableBalanceParams { + sourceSecret: string; + asset: AssetInput; + amount: string; + claimants: ClaimantInput[]; +} + +export interface CreateClaimableBalanceResult { + transactionHash: string; + /** Claimable balance IDs (hex) created by this transaction. */ + balanceIds: string[]; +} + +export interface ClaimClaimableBalanceParams { + claimerSecret: string; + balanceId: string; +} + +export interface ClaimClaimableBalanceResult { + transactionHash: string; + balanceId: string; +} + +export interface ListClaimableBalancesParams { + claimant?: string; + sponsor?: string; + asset?: AssetInput; + limit?: number; + cursor?: string; +} + +export interface ClaimableBalanceRecord { + id: string; + asset: string; + amount: string; + sponsor?: string; + lastModifiedLedger: number; + claimants: Array<{ destination: string; predicate: unknown }>; +} + +/** + * Stellar's per-balance protocol limit on claimants for a single + * CreateClaimableBalance operation. We currently emit one operation per input + * claimant (so each resulting balance has exactly one claimant), meaning this + * constant is informational and NOT used to cap the input array length. + */ +export const MAX_CLAIMANTS_PER_BALANCE = 10; + +/** + * Stellar's protocol cap on operations per transaction. Since we emit one + * CreateClaimableBalance op per input claimant, this is the effective upper + * bound on the size of the `claimants` array in a single call. + */ +const MAX_OPERATIONS_PER_TRANSACTION = 100; + +function getNetworkPassphrase(network: NetworkName): string { + return network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; +} + +function toAsset(input: AssetInput): Asset { + if (!input || !input.code) { + throw new Error("Asset code is required"); + } + if (input.code.toUpperCase() === "XLM" || input.code === "native") { + return Asset.native(); + } + if (!input.issuer) { + throw new Error(`Asset ${input.code} requires an issuer`); + } + return new Asset(input.code, input.issuer); +} + +/** + * Build a Stellar SDK xdr.ClaimPredicate from the friendly tagged union. + * + * Validates inputs (positive durations, balanced compound predicates, depth) + * to prevent accidentally creating unclaimable balances. + */ +export function buildPredicate( + predicate: ClaimPredicate | undefined, + depth = 0 +): xdr.ClaimPredicate { + if (depth > 5) { + throw new Error("Claim predicate nesting too deep (max 5 levels)"); + } + if (!predicate || predicate.type === "unconditional") { + return Claimant.predicateUnconditional(); + } + + switch (predicate.type) { + case "beforeRelativeTime": { + const seconds = String(predicate.seconds); + if (!/^\d+$/.test(seconds) || Number(seconds) <= 0) { + throw new Error("beforeRelativeTime.seconds must be a positive integer"); + } + return Claimant.predicateBeforeRelativeTime(seconds); + } + case "beforeAbsoluteTime": { + const epoch = String(predicate.epochSeconds); + if (!/^\d+$/.test(epoch) || Number(epoch) <= 0) { + throw new Error( + "beforeAbsoluteTime.epochSeconds must be a positive integer" + ); + } + return Claimant.predicateBeforeAbsoluteTime(epoch); + } + case "not": { + if (!predicate.predicate) { + throw new Error("`not` predicate requires an inner predicate"); + } + return Claimant.predicateNot(buildPredicate(predicate.predicate, depth + 1)); + } + case "and": { + if (!predicate.predicates || predicate.predicates.length !== 2) { + throw new Error("`and` predicate requires exactly 2 inner predicates"); + } + return Claimant.predicateAnd( + buildPredicate(predicate.predicates[0], depth + 1), + buildPredicate(predicate.predicates[1], depth + 1) + ); + } + case "or": { + if (!predicate.predicates || predicate.predicates.length !== 2) { + throw new Error("`or` predicate requires exactly 2 inner predicates"); + } + return Claimant.predicateOr( + buildPredicate(predicate.predicates[0], depth + 1), + buildPredicate(predicate.predicates[1], depth + 1) + ); + } + default: { + const exhaustive: never = predicate; + throw new Error(`Unsupported predicate type: ${JSON.stringify(exhaustive)}`); + } + } +} + +function validateAmount(amount: string): void { + if (typeof amount !== "string" || amount.trim() === "") { + throw new Error("amount must be a non-empty string"); + } + if (!/^\d+(\.\d{1,7})?$/.test(amount)) { + throw new Error( + "amount must be a positive decimal with up to 7 fractional digits" + ); + } + if (Number(amount) <= 0) { + throw new Error("amount must be greater than zero"); + } +} + +/** + * Extract the claimable balance IDs created by a CreateClaimableBalance op + * from the transaction's operation result XDR. + */ +export function extractBalanceIdsFromTransactionResult( + resultXdr: string +): string[] { + const ids: string[] = []; + let parsed: xdr.TransactionResult; + try { + parsed = xdr.TransactionResult.fromXDR(resultXdr, "base64"); + } catch (err) { + return ids; + } + + const inner = parsed.result(); + if (inner.switch().name !== "txSuccess" && inner.switch().name !== "txFeeBumpInnerSuccess") { + return ids; + } + + const opResults = + inner.switch().name === "txFeeBumpInnerSuccess" + ? inner.innerResultPair().result().result().results() + : inner.results(); + + for (const op of opResults) { + const tr = op.tr(); + if (!tr || tr.switch().name !== "createClaimableBalance") continue; + const cbResult = tr.createClaimableBalanceResult(); + if (cbResult.switch().name !== "createClaimableBalanceSuccess") continue; + const balanceId = cbResult.balanceId(); + ids.push(balanceId.toXDR("hex")); + } + + return ids; +} + +/** + * Create one or more claimable balances in a single transaction. + * + * Each claimant + predicate becomes its own CreateClaimableBalance operation + * so the resulting balance IDs map 1:1 with the input claimants array. + */ +export async function createClaimableBalance( + ctx: ClaimableBalanceContext, + params: CreateClaimableBalanceParams +): Promise { + if (!params.sourceSecret) { + throw new Error("sourceSecret is required"); + } + if (!params.claimants || params.claimants.length === 0) { + throw new Error("At least one claimant is required"); + } + if (params.claimants.length > MAX_OPERATIONS_PER_TRANSACTION) { + throw new Error( + `A single transaction supports at most ${MAX_OPERATIONS_PER_TRANSACTION} claimable-balance operations ` + + `(received ${params.claimants.length}). Split the request across multiple transactions.` + ); + } + validateAmount(params.amount); + + const sourceKp = Keypair.fromSecret(params.sourceSecret); + const asset = toAsset(params.asset); + const networkPassphrase = getNetworkPassphrase(ctx.network); + const server = new Horizon.Server(ctx.horizonUrl); + + const account = await server.loadAccount(sourceKp.publicKey()); + + const claimants = params.claimants.map( + (c) => new Claimant(c.destination, buildPredicate(c.predicate)) + ); + + const builder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }); + + // One operation per claimant for deterministic 1:1 balanceId mapping. + for (const claimant of claimants) { + builder.addOperation( + Operation.createClaimableBalance({ + asset, + amount: params.amount, + claimants: [claimant], + }) + ); + } + + const tx = builder.setTimeout(180).build(); + tx.sign(sourceKp); + + const result = await server.submitTransaction(tx); + const resultXdr = + (result as unknown as { result_xdr?: string }).result_xdr ?? ""; + const balanceIds = resultXdr + ? extractBalanceIdsFromTransactionResult(resultXdr) + : []; + + return { transactionHash: result.hash, balanceIds }; +} + +/** + * Claim a previously created claimable balance. + */ +export async function claimClaimableBalance( + ctx: ClaimableBalanceContext, + params: ClaimClaimableBalanceParams +): Promise { + if (!params.claimerSecret) { + throw new Error("claimerSecret is required"); + } + if (!params.balanceId || !/^[0-9a-fA-F]+$/.test(params.balanceId)) { + throw new Error("balanceId must be a hex string"); + } + + const claimerKp = Keypair.fromSecret(params.claimerSecret); + const networkPassphrase = getNetworkPassphrase(ctx.network); + const server = new Horizon.Server(ctx.horizonUrl); + + const account = await server.loadAccount(claimerKp.publicKey()); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.claimClaimableBalance({ balanceId: params.balanceId }) + ) + .setTimeout(180) + .build(); + + tx.sign(claimerKp); + const result = await server.submitTransaction(tx); + return { transactionHash: result.hash, balanceId: params.balanceId }; +} + +/** + * List claimable balances by claimant, sponsor, or asset. + */ +export async function listClaimableBalances( + ctx: ClaimableBalanceContext, + params: ListClaimableBalancesParams = {} +): Promise { + const server = new Horizon.Server(ctx.horizonUrl); + let call = server.claimableBalances(); + + if (params.claimant) call = call.claimant(params.claimant); + if (params.sponsor) call = call.sponsor(params.sponsor); + if (params.asset) call = call.asset(toAsset(params.asset)); + if (params.limit) call = call.limit(params.limit); + if (params.cursor) call = call.cursor(params.cursor); + + const page = await call.call(); + return page.records.map((r: any) => ({ + id: r.id, + asset: r.asset, + amount: r.amount, + sponsor: r.sponsor, + lastModifiedLedger: r.last_modified_ledger, + claimants: (r.claimants ?? []).map((c: any) => ({ + destination: c.destination, + predicate: c.predicate, + })), + })); +} diff --git a/package-lock.json b/package-lock.json index 184ce2b5..57e6b475 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5459,17 +5459,6 @@ } } }, - "node_modules/web3-eth-abi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/web3-eth-accounts": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/web3-eth-accounts/-/web3-eth-accounts-4.3.1.tgz", diff --git a/tests/unit/lib/claimableBalance.test.ts b/tests/unit/lib/claimableBalance.test.ts new file mode 100644 index 00000000..16b10908 --- /dev/null +++ b/tests/unit/lib/claimableBalance.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { Account, Keypair } from "@stellar/stellar-sdk"; + +import { + buildPredicate, + createClaimableBalance, + claimClaimableBalance, + listClaimableBalances, + extractBalanceIdsFromTransactionResult, + type ClaimPredicate, +} from "../../../lib/claimableBalance"; + +const TESTNET_HORIZON = "https://horizon-testnet.stellar.org"; + +// We avoid constructing a full xdr.TransactionResult in tests (the XDR union +// builders for TransactionResultExt vary across SDK versions). Instead we +// verify the extractor is robust to invalid input here, and let real-network +// integration tests cover end-to-end parsing. + +describe("claimableBalance.buildPredicate", () => { + it("builds an unconditional predicate by default", () => { + expect(() => buildPredicate(undefined)).not.toThrow(); + expect(() => buildPredicate({ type: "unconditional" })).not.toThrow(); + }); + + it("validates relative-time predicates", () => { + expect(() => + buildPredicate({ type: "beforeRelativeTime", seconds: 60 }) + ).not.toThrow(); + expect(() => + buildPredicate({ type: "beforeRelativeTime", seconds: 0 }) + ).toThrow(/positive integer/); + expect(() => + buildPredicate({ type: "beforeRelativeTime", seconds: -1 as any }) + ).toThrow(/positive integer/); + }); + + it("validates absolute-time predicates", () => { + expect(() => + buildPredicate({ type: "beforeAbsoluteTime", epochSeconds: "1735689600" }) + ).not.toThrow(); + expect(() => + buildPredicate({ type: "beforeAbsoluteTime", epochSeconds: "abc" as any }) + ).toThrow(/positive integer/); + }); + + it("supports nested compound predicates", () => { + const predicate: ClaimPredicate = { + type: "and", + predicates: [ + { type: "beforeRelativeTime", seconds: 100 }, + { type: "not", predicate: { type: "unconditional" } }, + ], + }; + expect(() => buildPredicate(predicate)).not.toThrow(); + }); + + it("rejects malformed compound predicates", () => { + expect(() => + buildPredicate({ type: "and", predicates: [] as any }) + ).toThrow(/exactly 2/); + expect(() => buildPredicate({ type: "not" } as any)).toThrow(/inner/); + }); + + it("guards against excessive nesting", () => { + let p: ClaimPredicate = { type: "unconditional" }; + for (let i = 0; i < 8; i++) { + p = { type: "not", predicate: p }; + } + expect(() => buildPredicate(p)).toThrow(/too deep/); + }); +}); + +describe("claimableBalance.extractBalanceIdsFromTransactionResult", () => { + it("returns empty array on malformed XDR", () => { + expect(extractBalanceIdsFromTransactionResult("not-xdr")).toEqual([]); + }); + + it("returns empty array when result_xdr is missing", () => { + expect(extractBalanceIdsFromTransactionResult("")).toEqual([]); + }); +}); + +describe("claimableBalance.createClaimableBalance", () => { + const sourceKp = Keypair.random(); + const destKp = Keypair.random(); + let loadAccountSpy: any; + let submitSpy: any; + + beforeEach(() => { + const account = new Account(sourceKp.publicKey(), "1"); + // Stub Horizon Server methods on the prototype. + const sdk = require("@stellar/stellar-sdk"); + loadAccountSpy = vi + .spyOn(sdk.Horizon.Server.prototype as any, "loadAccount") + .mockResolvedValue(account); + submitSpy = vi + .spyOn(sdk.Horizon.Server.prototype as any, "submitTransaction") + .mockResolvedValue({ + hash: "abc123", + // Empty result_xdr → balanceIds will be []. Real Horizon returns + // a populated XDR; integration tests cover end-to-end parsing. + result_xdr: "", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("validates inputs and rejects bad amount", async () => { + await expect( + createClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { + sourceSecret: sourceKp.secret(), + asset: { code: "XLM" }, + amount: "0", + claimants: [{ destination: destKp.publicKey() }], + } + ) + ).rejects.toThrow(/greater than zero/); + }); + + it("rejects empty claimants list", async () => { + await expect( + createClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { + sourceSecret: sourceKp.secret(), + asset: { code: "XLM" }, + amount: "10", + claimants: [], + } + ) + ).rejects.toThrow(/At least one claimant/); + }); + + it("accepts more than 10 claimants (each becomes its own 1-claimant balance)", async () => { + const claimants = Array.from({ length: 25 }, () => ({ + destination: Keypair.random().publicKey(), + })); + const result = await createClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { + sourceSecret: sourceKp.secret(), + asset: { code: "XLM" }, + amount: "10", + claimants, + } + ); + expect(result.transactionHash).toBe("abc123"); + const submittedTx = submitSpy.mock.calls[0][0]; + expect(submittedTx.operations).toHaveLength(25); + }); + + it("rejects more than 100 ops per transaction (Stellar protocol cap)", async () => { + const claimants = Array.from({ length: 101 }, () => ({ + destination: Keypair.random().publicKey(), + })); + await expect( + createClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { + sourceSecret: sourceKp.secret(), + asset: { code: "XLM" }, + amount: "10", + claimants, + } + ) + ).rejects.toThrow(/at most 100/); + }); + + it("requires issuer for non-native asset", async () => { + await expect( + createClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { + sourceSecret: sourceKp.secret(), + asset: { code: "USDC" }, + amount: "10", + claimants: [{ destination: destKp.publicKey() }], + } + ) + ).rejects.toThrow(/issuer/); + }); + + it("submits a signed tx and returns hash + balance ids", async () => { + const result = await createClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { + sourceSecret: sourceKp.secret(), + asset: { code: "XLM" }, + amount: "5", + claimants: [ + { + destination: destKp.publicKey(), + predicate: { type: "beforeRelativeTime", seconds: 3600 }, + }, + ], + } + ); + + expect(result.transactionHash).toBe("abc123"); + expect(Array.isArray(result.balanceIds)).toBe(true); + expect(loadAccountSpy).toHaveBeenCalledWith(sourceKp.publicKey()); + expect(submitSpy).toHaveBeenCalledTimes(1); + + // Verify the submitted tx was signed and contains a CreateClaimableBalance op. + const submittedTx = submitSpy.mock.calls[0][0]; + expect(submittedTx.signatures.length).toBeGreaterThan(0); + expect(submittedTx.operations).toHaveLength(1); + expect(submittedTx.operations[0].type).toBe("createClaimableBalance"); + }); +}); + +describe("claimableBalance.claimClaimableBalance", () => { + const claimerKp = Keypair.random(); + let loadAccountSpy: any; + let submitSpy: any; + + beforeEach(() => { + const sdk = require("@stellar/stellar-sdk"); + loadAccountSpy = vi + .spyOn(sdk.Horizon.Server.prototype as any, "loadAccount") + .mockResolvedValue(new Account(claimerKp.publicKey(), "1")); + submitSpy = vi + .spyOn(sdk.Horizon.Server.prototype as any, "submitTransaction") + .mockResolvedValue({ hash: "claim-hash", result_xdr: "" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects non-hex balance ids", async () => { + await expect( + claimClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { claimerSecret: claimerKp.secret(), balanceId: "not-hex!" } + ) + ).rejects.toThrow(/hex/); + }); + + it("submits a signed claim transaction", async () => { + const balanceId = "00000000" + "ab".repeat(32); + const result = await claimClaimableBalance( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { claimerSecret: claimerKp.secret(), balanceId } + ); + + expect(result.transactionHash).toBe("claim-hash"); + expect(result.balanceId).toBe(balanceId); + const submittedTx = submitSpy.mock.calls[0][0]; + expect(submittedTx.operations[0].type).toBe("claimClaimableBalance"); + }); +}); + +describe("claimableBalance.listClaimableBalances", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("queries Horizon with provided filters and maps records", async () => { + const sdk = require("@stellar/stellar-sdk"); + + const callBuilder: any = { + claimant: vi.fn().mockReturnThis(), + sponsor: vi.fn().mockReturnThis(), + asset: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + cursor: vi.fn().mockReturnThis(), + call: vi.fn().mockResolvedValue({ + records: [ + { + id: "00000000abc", + asset: "native", + amount: "10.0000000", + sponsor: "GSPONSOR", + last_modified_ledger: 42, + claimants: [ + { + destination: "GDEST", + predicate: { unconditional: true }, + }, + ], + }, + ], + }), + }; + + vi.spyOn(sdk.Horizon.Server.prototype as any, "claimableBalances").mockReturnValue( + callBuilder + ); + + const records = await listClaimableBalances( + { network: "testnet", horizonUrl: TESTNET_HORIZON }, + { claimant: "GDEST", limit: 5 } + ); + + expect(callBuilder.claimant).toHaveBeenCalledWith("GDEST"); + expect(callBuilder.limit).toHaveBeenCalledWith(5); + expect(records).toHaveLength(1); + expect(records[0].id).toBe("00000000abc"); + expect(records[0].claimants[0].destination).toBe("GDEST"); + }); +});