This guide covers how to interact with the Brain-Storm Soroban smart contracts deployed on the Stellar network. It targets backend (NestJS/TypeScript) and frontend (Next.js/TypeScript) developers, as well as Rust developers writing cross-contract calls.
- Prerequisites & Setup
- Contract Overview
- Analytics Contract
- Token Contract (BST)
- Certificate Contract
- Governance Contract
- Shared Contract (RBAC)
- Invoking Contracts from the Backend
- Invoking Contracts from the Frontend
- Error Handling Patterns
- Troubleshooting
STELLAR_SECRET_KEY=S... # Signing keypair (admin / backend issuer)
STELLAR_NETWORK=testnet # "testnet" or "mainnet"
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
ANALYTICS_CONTRACT_ID=C...
TOKEN_CONTRACT_ID=C...
CERTIFICATE_CONTRACT_ID=C...
GOVERNANCE_CONTRACT_ID=C...
SHARED_CONTRACT_ID=C...npm install @stellar/stellar-sdkimport {
Keypair, Networks, TransactionBuilder, BASE_FEE,
Operation, Address, nativeToScVal, xdr,
} from '@stellar/stellar-sdk';
import { SorobanRpc } from '@stellar/stellar-sdk';
const isTestnet = process.env.STELLAR_NETWORK !== 'mainnet';
const networkPassphrase = isTestnet ? Networks.TESTNET : Networks.PUBLIC;
const rpcUrl = process.env.SOROBAN_RPC_URL!;
const server = new SorobanRpc.Server(rpcUrl);
const issuerKeypair = Keypair.fromSecret(process.env.STELLAR_SECRET_KEY!);async function invokeContract(
contractId: string,
method: string,
args: xdr.ScVal[],
): Promise<string> {
const source = await server.getAccount(issuerKeypair.publicKey());
const tx = new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase })
.addOperation(Operation.invokeContractFunction({ contract: contractId, function: method, args }))
.setTimeout(30)
.build();
const prepared = await server.prepareTransaction(tx);
prepared.sign(issuerKeypair);
const result = await server.sendTransaction(prepared);
if (result.status === 'ERROR') throw new Error(`Contract call failed: ${result.hash}`);
// Poll for confirmation
let response = await server.getTransaction(result.hash);
while (response.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) {
await new Promise(r => setTimeout(r, 1000));
response = await server.getTransaction(result.hash);
}
if (response.status === SorobanRpc.Api.GetTransactionStatus.FAILED)
throw new Error(`Transaction failed: ${result.hash}`);
return result.hash;
}
async function simulateContract(
contractId: string,
method: string,
args: xdr.ScVal[],
): Promise<xdr.ScVal | undefined> {
const source = await server.getAccount(issuerKeypair.publicKey());
const tx = new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase })
.addOperation(Operation.invokeContractFunction({ contract: contractId, function: method, args }))
.setTimeout(30)
.build();
const sim = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`);
return (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result?.retval;
}| Contract | ID Env Var | Purpose |
|---|---|---|
| Analytics | ANALYTICS_CONTRACT_ID |
Records per-student, per-course progress on-chain |
| Token | TOKEN_CONTRACT_ID |
BST reward token (SEP-0041 compatible) |
| Certificate | CERTIFICATE_CONTRACT_ID |
Soulbound NFT certificates for course completion |
| Governance | GOVERNANCE_CONTRACT_ID |
Token-weighted proposal voting |
| Shared | SHARED_CONTRACT_ID |
RBAC — roles and permissions |
All contracts require an initialize(admin) call once after deployment. Double-initialization panics with "Already initialized".
Tracks student progress (0–100%) per course. Progress is stored in persistent storage with automatic TTL extension.
| Function | Auth Required | Description |
|---|---|---|
initialize(admin) |
admin | One-time setup |
set_admin(new_admin) |
current admin | Transfer admin |
get_admin() |
none | Read current admin |
record_progress(caller, student, course_id, progress_pct) |
caller (student or admin) | Write progress (0–100) |
reset_progress(admin, student, course_id) |
admin | Delete a progress record |
get_progress(student, course_id) |
none | Read one record |
get_all_progress(student) |
none | Read all records for a student |
ProgressRecord shape:
{ student, course_id, progress_pct: u32, completed: bool, timestamp: u64 }
Events emitted by record_progress:
("analytics", "prog_upd")→(student, course_id, progress_pct)— always("analytics", "completed")→(student, course_id)— only whenprogress_pct == 100
const ANALYTICS = process.env.ANALYTICS_CONTRACT_ID!;
// Record progress (backend, signed by admin/issuer)
await invokeContract(ANALYTICS, 'record_progress', [
new Address(issuerKeypair.publicKey()).toScVal(), // caller
new Address(studentPublicKey).toScVal(), // student
nativeToScVal('RUST101', { type: 'symbol' }), // course_id
nativeToScVal(75, { type: 'u32' }), // progress_pct
]);
// Read progress (simulate — no fee)
const retval = await simulateContract(ANALYTICS, 'get_progress', [
new Address(studentPublicKey).toScVal(),
nativeToScVal('RUST101', { type: 'symbol' }),
]);
// retval is an Option<ProgressRecord> ScVal; null means no recordlet progress: Option<ProgressRecord> = env.invoke_contract(
&analytics_contract_id,
&soroban_sdk::symbol_short!("get_progress"),
soroban_sdk::vec![&env, student.into_val(&env), course_id.into_val(&env)],
);SEP-0041 compatible fungible token. Max supply: 1 billion BST (stored with 7 decimal places, so 1_000_000_0000000 raw units). Includes a reentrancy guard on mutating operations.
| Function | Auth Required | Description |
|---|---|---|
initialize(admin) |
admin | One-time setup |
name() |
none | Returns "Brain-Storm Token" |
symbol() |
none | Returns "BST" |
decimals() |
none | Returns 7 |
balance(addr) |
none | Token balance for address |
total_supply() |
none | Total minted supply |
mint(to, amount) |
admin | Mint tokens (admin only) |
mint_reward(caller, recipient, amount) |
caller (admin) | Mint reward tokens |
burn(from, amount) |
from | Burn own tokens |
burn_from(spender, from, amount) |
spender | Burn with allowance |
transfer(from, to, amount) |
from | Transfer tokens |
transfer_from(spender, from, to, amount) |
spender | Transfer with allowance |
approve(owner, spender, amount) |
owner | Set allowance |
allowance(owner, spender) |
none | Read allowance |
create_vesting(admin, beneficiary, total, start, cliff, end) |
admin | Create vesting schedule |
claim_vesting(beneficiary) |
beneficiary | Claim vested tokens |
get_vesting(beneficiary) |
none | Read vesting schedule |
Events:
("transfer", "to", to)→amount("approve", ...)→amount("mint", "to", to)→amount("burn", "from", from)→amount
const TOKEN = process.env.TOKEN_CONTRACT_ID!;
// Read balance (simulate)
const retval = await simulateContract(TOKEN, 'balance', [
new Address(studentPublicKey).toScVal(),
]);
const balance = retval ? BigInt(retval.value() as bigint).toString() : '0';
// Mint reward (state-changing, signed by admin)
await invokeContract(TOKEN, 'mint_reward', [
new Address(issuerKeypair.publicKey()).toScVal(), // caller (admin)
new Address(studentPublicKey).toScVal(), // recipient
nativeToScVal(100_0000000, { type: 'i128' }), // 100 BST (7 decimals)
]);
// Transfer (signed by token holder — requires their keypair)
const holderKeypair = Keypair.fromSecret(holderSecret);
// Build tx with holderKeypair as source and signerDecimal note: All amounts are in raw units. 1 BST =
10_000_000(7 decimal places). Always multiply user-facing amounts by10_000_000before passing to the contract.
Issues soulbound (non-transferable) NFT certificates. Each certificate has an auto-incrementing u64 ID. Attempting to call transfer panics with "soulbound".
| Function | Auth Required | Description |
|---|---|---|
initialize(admin) |
admin | One-time setup |
get_admin() |
none | Read admin |
mint_certificate(admin, recipient, course_id, metadata_url) |
admin | Issue certificate, returns u64 ID |
get_certificate(id) |
none | Fetch by ID → Option<CertificateRecord> |
get_certificates_by_owner(owner) |
none | All certificates for an address |
transfer(...) |
— | Always panics ("soulbound") |
CertificateRecord shape:
{ id: u64, owner, course_id, metadata_url: String, issued_at: u64 }
Events:
("mint", "to", recipient)→(id, course_id)
const CERT = process.env.CERTIFICATE_CONTRACT_ID!;
// Mint a certificate
await invokeContract(CERT, 'mint_certificate', [
new Address(issuerKeypair.publicKey()).toScVal(), // admin
new Address(studentPublicKey).toScVal(), // recipient
nativeToScVal('RUST101', { type: 'symbol' }), // course_id
nativeToScVal('https://brainstorm.io/certs/42', { type: 'string' }), // metadata_url
]);
// Fetch all certificates for a student
const retval = await simulateContract(CERT, 'get_certificates_by_owner', [
new Address(studentPublicKey).toScVal(),
]);Token-weighted proposal voting. Voting power equals the voter's BST balance at vote time (cross-contract call to the Token contract). A proposal passes when votes_for > votes_against after the voting period ends.
| Function | Auth Required | Description |
|---|---|---|
initialize(admin, token_contract) |
admin | One-time setup |
get_admin() |
none | Read admin |
create_proposal(proposer, title, description, voting_end_ledger) |
proposer | Create proposal, returns u64 ID |
vote(voter, proposal_id, support) |
voter | Cast vote (once per address) |
execute_proposal(proposal_id) |
none | Execute a passed proposal |
get_proposal(proposal_id) |
none | Fetch proposal → Option<ProposalRecord> |
has_voted(proposal_id, voter) |
none | Check if address has voted |
ProposalRecord shape:
{ id, proposer, title, description, voting_end_ledger: u32,
votes_for: i128, votes_against: i128, executed: bool, created_at: u64 }
Events:
("prop_new", "id")→id("vote", "voter")→(proposal_id, support)("exec", ...)on execution
const GOV = process.env.GOVERNANCE_CONTRACT_ID!;
// Create a proposal (proposer signs)
await invokeContract(GOV, 'create_proposal', [
new Address(proposerPublicKey).toScVal(),
nativeToScVal('Increase reward rate', { type: 'string' }),
nativeToScVal('Proposal to increase BST rewards by 10%', { type: 'string' }),
nativeToScVal(currentLedger + 1000, { type: 'u32' }), // voting_end_ledger
]);
// Vote (voter signs — requires voter's keypair as transaction source)
// Cast vote: support=true means "for"
await invokeContract(GOV, 'vote', [
new Address(voterPublicKey).toScVal(),
nativeToScVal(1n, { type: 'u64' }), // proposal_id
nativeToScVal(true, { type: 'bool' }),
]);
// Execute after voting ends
await invokeContract(GOV, 'execute_proposal', [
nativeToScVal(1n, { type: 'u64' }),
]);Note:
voterequires the voter's auth. The backend cannot sign on behalf of voters. This call must originate from the frontend with the user's wallet.
Provides role assignment and permission checks used across the platform.
| Role | Permissions |
|---|---|
Admin |
All permissions |
Instructor |
CreateCourse, EnrollStudent |
Student |
None |
Available permissions: CreateCourse, EnrollStudent, IssueCredential, MintToken, ManageUsers.
| Function | Auth Required | Description |
|---|---|---|
initialize(admin) |
admin | One-time setup |
assign_role(caller, target, role) |
admin | Assign role to address |
has_role(addr, role) |
none | Check role |
has_permission(addr, permission) |
none | Check permission |
upgrade(admin, new_wasm_hash) |
admin | Upgrade contract WASM |
Events:
("rbac", "role_asgn")→(target, role)
const SHARED = process.env.SHARED_CONTRACT_ID!;
// Assign instructor role
await invokeContract(SHARED, 'assign_role', [
new Address(issuerKeypair.publicKey()).toScVal(), // caller (admin)
new Address(instructorPublicKey).toScVal(), // target
// Role enum variant — encode as ScVal symbol
xdr.ScVal.scvVec([xdr.ScVal.scvSymbol('Instructor')]),
]);
// Check permission (simulate)
const retval = await simulateContract(SHARED, 'has_permission', [
new Address(instructorPublicKey).toScVal(),
xdr.ScVal.scvVec([xdr.ScVal.scvSymbol('CreateCourse')]),
]);
const hasPermission = retval?.value() === true;The StellarService in apps/backend/src/stellar/stellar.service.ts is the central integration point. Extend it for new contract calls.
Use simulateTransaction — no fee, no signing required beyond building the transaction.
// In StellarService
async getProgress(studentPublicKey: string, courseId: string) {
return this.simulateContract(
this.analyticsContractId,
'get_progress',
[
new Address(studentPublicKey).toScVal(),
nativeToScVal(courseId, { type: 'symbol' }),
],
);
}Use prepareTransaction → sign → sendTransaction → poll.
// In StellarService
async recordProgress(studentPublicKey: string, courseId: string, pct: number) {
return this.retryWithBackoff(() =>
this.invokeContract(this.analyticsContractId, 'record_progress', [
new Address(this.issuerKeypair.publicKey()).toScVal(),
new Address(studentPublicKey).toScVal(),
nativeToScVal(courseId, { type: 'symbol' }),
nativeToScVal(pct, { type: 'u32' }),
]),
);
}The existing retryWithBackoff helper handles transient RPC failures with exponential backoff (3 attempts by default).
Wrap simulate calls with the injected CacheManager to avoid redundant RPC calls:
const cacheKey = `progress:${studentPublicKey}:${courseId}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) return cached;
const result = await this.simulateContract(...);
await this.cacheManager.set(cacheKey, result, 30_000); // 30s TTL
return result;The frontend should only call read-only (simulate) operations directly. State-changing operations that require the user's wallet signature use Freighter or a compatible Stellar wallet.
// apps/frontend — e.g. in a React hook
import { SorobanRpc, TransactionBuilder, BASE_FEE, Networks, Operation, Address, nativeToScVal, Keypair } from '@stellar/stellar-sdk';
const server = new SorobanRpc.Server(process.env.NEXT_PUBLIC_SOROBAN_RPC_URL!);
async function fetchTokenBalance(publicKey: string): Promise<string> {
// Use a throwaway keypair for simulation source — no secret needed
const dummyKeypair = Keypair.random();
const source = await server.getAccount(dummyKeypair.publicKey()).catch(() => {
// Fallback: use a known funded account as simulation source
throw new Error('Configure a funded simulation account');
});
const tx = new TransactionBuilder(source, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(Operation.invokeContractFunction({
contract: process.env.NEXT_PUBLIC_TOKEN_CONTRACT_ID!,
function: 'balance',
args: [new Address(publicKey).toScVal()],
}))
.setTimeout(30)
.build();
const sim = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(sim)) throw new Error(sim.error);
const retval = (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result?.retval;
return retval ? BigInt(retval.value() as bigint).toString() : '0';
}Tip: For frontend simulations, route through the backend API (
GET /v1/users/:id/token-balance) to avoid exposing RPC URLs and to benefit from server-side caching.
import freighter from '@stellar/freighter-api';
async function castVote(proposalId: bigint, support: boolean) {
const publicKey = await freighter.getPublicKey();
const source = await server.getAccount(publicKey);
const tx = new TransactionBuilder(source, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(Operation.invokeContractFunction({
contract: process.env.NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID!,
function: 'vote',
args: [
new Address(publicKey).toScVal(),
nativeToScVal(proposalId, { type: 'u64' }),
nativeToScVal(support, { type: 'bool' }),
],
}))
.setTimeout(30)
.build();
const prepared = await server.prepareTransaction(tx);
const signedXdr = await freighter.signTransaction(prepared.toXDR(), {
networkPassphrase: Networks.TESTNET,
});
const result = await server.sendTransaction(
TransactionBuilder.fromXDR(signedXdr, Networks.TESTNET),
);
return result.hash;
}Soroban contract panics surface as simulation or submission errors. Map them to meaningful application errors:
async function safeInvoke(contractId: string, method: string, args: xdr.ScVal[]) {
try {
return await invokeContract(contractId, method, args);
} catch (err: any) {
const msg: string = err.message ?? '';
if (msg.includes('Already initialized')) throw new ConflictException('Contract already initialized');
if (msg.includes('Unauthorized')) throw new ForbiddenException('Caller not authorized');
if (msg.includes('Progress must be 0-100')) throw new BadRequestException('Progress must be between 0 and 100');
if (msg.includes('Insufficient balance')) throw new BadRequestException('Insufficient token balance');
if (msg.includes('Already voted')) throw new ConflictException('Address has already voted on this proposal');
if (msg.includes('Voting period ended')) throw new BadRequestException('Voting period has ended');
if (msg.includes('Proposal did not pass')) throw new BadRequestException('Proposal did not reach quorum');
if (msg.includes('soulbound')) throw new BadRequestException('Certificates are non-transferable');
if (msg.includes('Only admin')) throw new ForbiddenException('Admin authorization required');
throw new InternalServerErrorException(`Contract error: ${msg}`);
}
}For operations where the off-chain record is the source of truth (e.g. progress tracking), catch and log contract errors without failing the request:
try {
txHash = await this.stellarService.recordProgress(publicKey, courseId, pct);
} catch (err) {
this.logger.warn(`On-chain progress record failed (non-fatal): ${err.message}`);
}
// Continue saving to DB regardlessUse the existing retryWithBackoff in StellarService for all state-changing calls. For reads, a single retry is usually sufficient:
const result = await Promise.race([
this.simulateContract(...),
new Promise((_, reject) => setTimeout(() => reject(new Error('RPC timeout')), 10_000)),
]);The contract's initialize function was called more than once. Each contract can only be initialized once. Re-deploy a fresh contract instance if you need to reset state.
The corresponding env var is missing or empty. Verify .env contains all five contract IDs and that ConfigService maps them correctly in apps/backend/src/config/configuration.ts.
This usually means the ledger state changed between simulation and submission (e.g. another transaction modified the same storage entry). Retry the full build → simulate → submit cycle.
The Token and Analytics contracts have reentrancy guards. This panic means a contract call triggered a re-entry into the same contract. This should not happen in normal usage — check for unexpected cross-contract call chains.
The voter's BST balance is 0 at vote time. The Governance contract reads the balance via a cross-contract call to the Token contract. Ensure the voter holds BST before voting.
The Soroban RPC may take several seconds to index a submitted transaction. The polling loop in invokeContract waits up to ~30 seconds. If it times out, check the transaction hash on Stellar Expert or the Stellar Laboratory.
fundTestnetAccount is gated to testnet. On mainnet, accounts must be funded via a real XLM transfer.
get_progressreturnsNoneif no record exists for that(student, course_id)pair.get_certificates_by_ownerreturns an empty Vec if the student has no certificates.get_proposalreturnsNonefor unknown proposal IDs.
Always handle the Option / empty case in your application code before accessing fields.
Analytics and Certificate records use persistent storage with TTL thresholds (TTL_THRESHOLD=100, TTL_EXTEND_TO=500 ledgers). Any read operation automatically extends the TTL. If a record has expired, it will return None as if it never existed. For long-lived data, consider archiving records off-chain.