Skip to content
Open
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
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -331,6 +332,115 @@ 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. Exposed in three ways:

1. Programmatic API on `AgentClient` → `agent.claimable.*`
2. LangChain `DynamicStructuredTool` → `StellarClaimableBalanceTool` (auto-included in `stellarTools`)
3. Lower-level functions in `lib/claimableBalance` (`createClaimableBalance`, `claimClaimableBalance`, `listClaimableBalances`, `buildPredicate`)

### Programmatic API

```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],
});
```

### Composable Predicates

| 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 |

```typescript
// Two-party escrow: claimable AFTER `releaseAt` AND BEFORE 24h elapse
const predicate = {
type: "and",
predicates: [
{ type: "beforeRelativeTime", seconds: 86400 },
{
type: "not",
predicate: { type: "beforeAbsoluteTime", epochSeconds: releaseAt },
},
],
};
```

### Defaults & Overrides

The lib exposes the relevant Stellar protocol limits as named exports
(no magic numbers in user code):

```typescript
import {
MAX_CLAIMANTS_PER_BALANCE, // 10 (per-balance protocol cap)
MAX_OPERATIONS_PER_TRANSACTION, // 100 (default per-tx ops cap)
MAX_PREDICATE_DEPTH, // 5 (default predicate nesting)
DEFAULT_TRANSACTION_TIMEOUT_SECONDS,
} from "stellartools";

// All of these are overridable per-call via `options`:
await agent.claimable.create({
sourceSecret,
asset: { code: "XLM" },
amount: "1",
claimants,
options: {
maxOperationsPerTransaction: 50,
maxPredicateDepth: 3,
transactionTimeoutSeconds: 60,
baseFee: "200",
},
});
```

### LangChain Tool

`StellarClaimableBalanceTool` is auto-bundled into `stellarTools`, so AI
agents can call `create`, `claim`, and `list` directly via natural language:

> _"Lock 50 XLM for GABCD... claimable any time within the next 2 hours."_

It reads `STELLAR_PRIVATE_KEY` for `create`, and either
`STELLAR_CLAIMER_PRIVATE_KEY` (preferred) or `STELLAR_PRIVATE_KEY` for
`claim`. The tool always returns JSON (`{ ok, ... }`) so agents can parse
the result deterministically.

A runnable example lives at `examples/claimable-balance-example.ts`.

---

## 🌐 Supported Networks

- **Testnet** - Full support, no restrictions, safe for development
Expand Down
72 changes: 72 additions & 0 deletions agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ 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 ClaimableBalanceOptions,
type ClaimPredicate,
type ClaimantInput,
} from "./lib/claimableBalance";
import { stellarGetBalanceTool, stellarGetAccountInfoTool } from "./tools/stellar";
import {
Horizon,
Expand Down Expand Up @@ -67,6 +81,15 @@ export type {
RouteQuote,
SwapBestRouteParams,
SwapBestRouteResult,
CreateClaimableBalanceParams,
CreateClaimableBalanceResult,
ClaimClaimableBalanceParams,
ClaimClaimableBalanceResult,
ListClaimableBalancesParams,
ClaimableBalanceRecord,
ClaimableBalanceOptions,
ClaimPredicate,
ClaimantInput,
};

export class AgentClient {
Expand Down Expand Up @@ -284,6 +307,55 @@ 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.
*
* Each input claimant becomes its own `CreateClaimableBalance` operation,
* so the returned `balanceIds` map 1:1 with the input `claimants` array.
*
* @example
* // Lock 100 XLM for `recipient`, claimable any time within the next 24h
* await agent.claimable.create({
* sourceSecret: process.env.SOURCE_SECRET!,
* asset: { code: "XLM" },
* amount: "100",
* claimants: [{
* destination: recipient,
* predicate: { type: "beforeRelativeTime", seconds: 86400 },
* }],
* });
*/
public claimable = {
create: async (
params: CreateClaimableBalanceParams
): Promise<CreateClaimableBalanceResult> => {
return await cbCreate(
{ network: this.network, horizonUrl: this.rpcUrl },
params
);
},

claim: async (
params: ClaimClaimableBalanceParams
): Promise<ClaimClaimableBalanceResult> => {
return await cbClaim(
{ network: this.network, horizonUrl: this.rpcUrl },
params
);
},

list: async (
params?: ListClaimableBalancesParams
): Promise<ClaimableBalanceRecord[]> => {
return await cbList(
{ network: this.network, horizonUrl: this.rpcUrl },
params ?? {}
);
},
};

/**
* Launch a new token on the Stellar network.
*
Expand Down
163 changes: 163 additions & 0 deletions examples/claimable-balance-example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
* Claimable Balance Usage Example
*
* Demonstrates the three claimable-balance flows exposed by AgentKit:
* 1. create — lock funds with a (possibly composite) claim predicate
* 2. list — discover open claimable balances for a recipient
* 3. claim — release the locked funds to the recipient
*
* Use cases this enables for AI agents:
* - Conditional payouts (only pay if X happens before deadline)
* - Vesting / scheduled payments
* - Two-party escrow (either party can claim before / after deadline)
*
* ⚠️ TESTNET ONLY. Never use mainnet secrets in examples.
*
* Account funding
* ---------------
* The example needs two funded testnet accounts. It supports two modes:
*
* A. Set env vars (preferred for repeated runs):
* EXAMPLE_SOURCE_SECRET=S...
* EXAMPLE_RECIPIENT_SECRET=S...
* The accounts must already be funded on testnet.
*
* B. Leave the env vars unset and the script will:
* - generate two fresh keypairs,
* - auto-fund them via the Friendbot faucet, and
* - print the secrets so you can re-use them on subsequent runs.
*
* Run with: ts-node examples/claimable-balance-example.ts
*/

import { AgentClient } from "../agent";
import { Horizon, Keypair } from "@stellar/stellar-sdk";

const HORIZON_URL = "https://horizon-testnet.stellar.org";
const FRIENDBOT_URL = "https://friendbot.stellar.org";

/**
* Resolve a keypair from an env var (preferred) or generate + fund a fresh one
* via Friendbot. Verifies on-chain funding before returning so the rest of the
* example can rely on the account existing.
*/
async function resolveAccount(
label: string,
envVar: string,
server: Horizon.Server
): Promise<Keypair> {
const fromEnv = process.env[envVar];
if (fromEnv) {
const kp = Keypair.fromSecret(fromEnv);
await server.loadAccount(kp.publicKey()); // throws if not funded
console.log(` ✓ ${label}: ${kp.publicKey()} (from ${envVar})`);
return kp;
}

const kp = Keypair.random();
console.log(` … ${label}: ${kp.publicKey()} (funding via Friendbot…)`);

const res = await fetch(`${FRIENDBOT_URL}?addr=${encodeURIComponent(kp.publicKey())}`);
if (!res.ok) {
throw new Error(
`Friendbot funding failed for ${label} (${res.status} ${res.statusText}). ` +
`Set ${envVar} to a pre-funded testnet secret to skip Friendbot.`
);
}
await server.loadAccount(kp.publicKey());
console.log(` ✓ ${label} funded. Save the secret to re-use on next run:`);
console.log(` export ${envVar}=${kp.secret()}`);
return kp;
}

async function exampleClaimableBalances() {
console.log("🔒 Claimable Balance Example");
console.log("=".repeat(60));

const agent = new AgentClient({ network: "testnet" });
const server = new Horizon.Server(HORIZON_URL);

console.log("\nResolving testnet accounts:");
const source = await resolveAccount("source ", "EXAMPLE_SOURCE_SECRET", server);
const recipient = await resolveAccount("recipient", "EXAMPLE_RECIPIENT_SECRET", server);

// ─── 1. Create ─────────────────────────────────────────────────────────
// Lock 50 XLM for the recipient. Two layered conditions, expressed as a
// composite predicate:
// - claimable for the next 24h (beforeRelativeTime: 86400s)
// - AND only after the configured "release time" has passed
// (i.e. NOT before that absolute timestamp)
const releaseAtUnix = Math.floor(Date.now() / 1000) + 60; // 60s from now

console.log("\nCreating claimable balance with composite predicate:");
console.log(` Amount: 50 XLM`);
console.log(` Window: next 24h`);
console.log(` Earliest: ${new Date(releaseAtUnix * 1000).toISOString()}`);

const created = await agent.claimable.create({
sourceSecret: source.secret(),
asset: { code: "XLM" },
amount: "50",
claimants: [
{
destination: recipient.publicKey(),
predicate: {
type: "and",
predicates: [
{ type: "beforeRelativeTime", seconds: 86400 },
{
type: "not",
predicate: {
type: "beforeAbsoluteTime",
epochSeconds: releaseAtUnix,
},
},
],
},
},
],
});

console.log("\n✅ Created!");
console.log(` Tx hash: ${created.transactionHash}`);
console.log(` Balance id: ${created.balanceIds[0]}`);

// ─── 2. List ───────────────────────────────────────────────────────────
console.log("\nListing open claimable balances for recipient...");
const open = await agent.claimable.list({
claimant: recipient.publicKey(),
});
console.log(` Found ${open.length} balance(s).`);
open.forEach((b) =>
console.log(` - ${b.id} amount=${b.amount} asset=${b.asset}`)
);

// ─── 3. Claim ──────────────────────────────────────────────────────────
// Wait until the predicate window opens, then claim. Horizon rejects claims
// whose predicate is not yet satisfied with a descriptive error — we surface
// that for visibility before retrying.
const waitMs = Math.max(0, releaseAtUnix * 1000 - Date.now()) + 2_000;
console.log(
`\nWaiting ${Math.ceil(waitMs / 1000)}s for the predicate to open before claiming…`
);
await new Promise((r) => setTimeout(r, waitMs));

try {
const claimed = await agent.claimable.claim({
claimerSecret: recipient.secret(),
balanceId: created.balanceIds[0],
});
console.log(`✅ Claimed! Tx hash: ${claimed.transactionHash}`);
} catch (err) {
console.log(
`❌ Claim failed: ${(err as Error).message}\n` +
` If the error is 'predicate not satisfied', the example may have been ` +
`interrupted before the window opened — re-run to retry.`
);
}
}

exampleClaimableBalances().catch((err) => {
console.error("Example failed:", err);
process.exit(1);
});
Loading