This document explains how to generate, version, and consume TypeScript bindings for the StellarStream Soroban contract. Follow this guide whenever the contract ABI changes or you are setting up the frontend for the first time.
soroban contract bindings typescript reads a deployed contract's ABI from the
network and generates a fully-typed TypeScript client. StellarStream keeps that
output in frontend/src/contracts/generated/ — a folder that is gitignored
and must be regenerated locally or in CI before the frontend can call the contract
directly.
contracts/src/lib.rs ← Rust source of truth
│ build + deploy
▼
Stellar Testnet ← CONTRACT_ID lives here
│ soroban contract bindings typescript
▼
frontend/src/contracts/generated/ ← gitignored, regenerate as needed
│ import
▼
frontend/src/services/contractClient.ts ← thin wrapper used by the app
| Tool | Version | Notes |
|---|---|---|
soroban-cli |
latest | cargo install --locked soroban-cli |
Rust + wasm32-unknown-unknown |
stable | needed to build the contract |
| Node.js | 18+ | for the frontend |
| A deployed contract | — | run npm run deploy:contract first |
SECRET_KEY="S..." npm run deploy:contractThe script saves the contract ID to contracts/contract_id.txt.
# Read the saved contract ID and generate
CONTRACT_ID=$(cat contracts/contract_id.txt) npm run gen:bindings
# Or pass it directly
CONTRACT_ID="C..." npm run gen:bindingsWhat the script does:
- Wipes
frontend/src/contracts/generated/(prevents stale files) - Calls
soroban contract bindings typescriptagainst the deployed contract - Writes the generated package into the output directory
- Prints the next-step instructions
Optional env overrides:
| Variable | Default | Purpose |
|---|---|---|
RPC_URL |
https://soroban-testnet.stellar.org:443 |
Target RPC endpoint |
NETWORK_PASSPHRASE |
Test SDF Network ; September 2015 |
Network passphrase |
After running the command, frontend/src/contracts/generated/ will contain:
generated/
├── index.ts ← main export: Contract class + all types
├── methods.ts ← one typed function per contract method
└── types.ts ← Stream, StreamCreated, StreamClaimed, StreamCanceled structs
| Rust type | TypeScript type | Description |
|---|---|---|
Stream |
Stream |
Full stream record with sender, recipient, token, amounts, times, canceled flag |
StreamCreated |
StreamCreated |
Event emitted on create_stream |
StreamClaimed |
StreamClaimed |
Event emitted on claim |
StreamCanceled |
StreamCanceled |
Event emitted on cancel |
| Contract method | TypeScript signature | Notes |
|---|---|---|
create_stream |
createStream(sender, recipient, token, totalAmount, startTime, endTime) → u64 |
Returns new stream ID |
get_stream |
getStream(streamId) → Stream |
Read-only |
get_next_stream_id |
getNextStreamId() → u64 |
Read-only |
claimable |
claimable(streamId, atTime) → i128 |
Read-only, returns claimable amount at a given timestamp |
claim |
claim(streamId, recipient, amount) → i128 |
Requires recipient auth |
cancel |
cancel(streamId, sender) |
Requires sender auth, refunds unvested amount |
Create a thin wrapper at frontend/src/services/contractClient.ts so components
never import from generated/ directly:
// frontend/src/services/contractClient.ts
import { Contract } from "../contracts/generated";
const CONTRACT_ID = import.meta.env.VITE_CONTRACT_ID ?? "";
const RPC_URL =
import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org:443";
const NETWORK_PASSPHRASE =
import.meta.env.VITE_NETWORK_PASSPHRASE ??
"Test SDF Network ; September 2015";
export const streamContract = new Contract({
contractId: CONTRACT_ID,
rpcUrl: RPC_URL,
networkPassphrase: NETWORK_PASSPHRASE,
});The following locations in frontend/src/services/api.ts are where direct
contract calls will replace (or augment) the current REST API calls once
wallet signing is wired up:
// CURRENT (REST API via backend)
export async function createStream(payload: CreateStreamPayload): Promise<Stream> {
const response = await fetch(`${API_BASE}/streams`, { method: "POST", ... });
...
}
// FUTURE (direct contract call, requires wallet signer)
// import { streamContract } from "./contractClient";
// const streamId = await streamContract.createStream(
// sender, recipient, tokenAddress, totalAmount, startTime, endTime
// );// CURRENT (REST API via backend)
export async function cancelStream(streamId: string): Promise<Stream> {
const response = await fetch(`${API_BASE}/streams/${streamId}/cancel`, { method: "POST" });
...
}
// FUTURE (direct contract call, requires sender auth)
// await streamContract.cancel(BigInt(streamId), senderAddress);// FUTURE — read claimable amount directly from chain (no backend needed)
// import { streamContract } from "./contractClient";
// const claimable = await streamContract.claimable(
// BigInt(streamId),
// BigInt(Math.floor(Date.now() / 1000))
// );// FUTURE — recipient claims vested tokens directly from contract
// await streamContract.claim(BigInt(streamId), recipientAddress, amount);To create a stream, the sender must authorize the transaction. The tokens are transferred from the sender's account to the contract's escrow.
import { streamContract } from "./contractClient";
async function handleCreateStream(sender: string, recipient: string, token: string) {
try {
const totalAmount = BigInt(1000 * 10**7); // 1000 tokens with 7 decimals
const startTime = BigInt(Math.floor(Date.now() / 1000));
const endTime = startTime + BigInt(30 * 24 * 60 * 60); // 30 days
const cliffSeconds = BigInt(0);
// Metadata is optional
const metadata = new Map([
["label", "Monthly Salary"],
["project", "StellarStream"]
]);
const streamId = await streamContract.createStream({
sender,
recipient,
token,
total_amount: totalAmount,
start_time: startTime,
end_time: endTime,
cliff_seconds: cliffSeconds,
metadata
});
console.log(`Stream created with ID: ${streamId}`);
} catch (err) {
console.error("Failed to create stream:", err);
}
}The recipient can claim any amount up to the current claimable total. This requires Freighter (or another wallet) to sign for the recipient's Address.
import { streamContract } from "./contractClient";
async function handleClaim(streamId: bigint, recipient: string, amount: bigint) {
try {
// This will trigger a wallet popup for the recipient to authorize
const claimed = await streamContract.claim({
stream_id: streamId,
recipient,
amount
});
console.log(`Successfully claimed ${claimed} tokens`);
} catch (err) {
// See "Error Handling" section below for common error codes
console.error("Claim failed:", err);
}
}Instead of calling claimable for every stream in a list, use the batch method.
import { streamContract } from "./contractClient";
async function fetchBalances(streamIds: bigint[]) {
const now = BigInt(Math.floor(Date.now() / 1000));
// Returns a Map<bigint, bigint>
const balances = await streamContract.getClaimableBatch({
stream_ids: streamIds,
at_time: now
});
streamIds.forEach(id => {
console.log(`Stream ${id} balance: ${balances.get(id)}`);
});
}The Soroban contract will panic with specific messages if validation fails. The TypeScript client captures these as errors.
| Error Message | Cause |
|---|---|
total_amount must be positive |
total_amount is 0 or negative. |
end_time must be greater than start_time |
Invalid time range provided. |
insufficient sender balance |
Sender does not have enough tokens to escrow. |
recipient mismatch |
The address calling claim is not the stream's recipient. |
amount exceeds claimable |
Trying to claim more than what has vested. |
too many stream ids |
get_claimable_batch called with more than 20 IDs. |
stream canceled |
Action attempted on a canceled stream. |
try {
await streamContract.claim({ ... });
} catch (err: any) {
if (err.message.includes("amount exceeds claimable")) {
// Handle specific business logic error
}
}Any time contracts/src/lib.rs changes a method signature or adds/removes a
public method:
- Rebuild and redeploy:
SECRET_KEY="S..." npm run deploy:contract - Update
CONTRACT_IDinbackend/.env - Regenerate bindings:
CONTRACT_ID=$(cat contracts/contract_id.txt) npm run gen:bindings - Update
contractClient.tsif new methods need to be exposed - Update
VITE_CONTRACT_IDinfrontend/.envif needed
To regenerate bindings in a CI pipeline, add a step after deployment:
- name: Generate contract bindings
env:
CONTRACT_ID: ${{ steps.deploy.outputs.contract_id }}
run: npm run gen:bindingsThe generated files do not need to be committed they can be regenerated from the deployed contract ID on every CI run.
The following lines should be present in .gitignore:
# Generated Soroban contract bindings — regenerate with: npm run gen:bindings
frontend/src/contracts/generated/*
!frontend/src/contracts/generated/README.md
This keeps the folder tracked so contributors know where to look while excluding the generated output which changes with every deployment.