Complete system design & architecture for Stellar/Soroban (production-ready, opinionated)
- Frontend: Next.js (App Router) +
@stellar/stellar-sdk+@stellar/freighter-api - Wallet / Auth: Freighter (primary), Lobstr, Albedo, xBull
- Contracts: Rust + Soroban SDK, Stellar CLI (
stellar) - Oracle model: Off-chain ed25519 signer
- Fees: Stellar resource fees (~0.0001 XLM per transaction)
- Backend: NestJS (Oracle worker, relayer, indexer)
- DB: PostgreSQL + Redis + BullMQ
- Price sources: DexScreener (primary) + StellarX/SDEX (fallback)
- Indexing: SorobanRPC
getEvents+ self-managed PostgreSQL persistence - Storage: IPFS for call metadata (Pinata or self-hosted)
- Goals & invariants
- High-level architecture diagrams
- Component responsibilities
- On-chain design — Soroban contracts
- Storage patterns & TTL management
- Oracle & outcome verification (ed25519)
- Off-chain backend (NestJS)
- Frontend — Stellar wallet integration
- Indexing — SorobanRPC & Horizon
- Content storage (IPFS)
- Security & audit checklist
- Failure modes & mitigations
- Deployments & CI/CD
- MVP roadmap
- Appendices
- Calls are immutable prediction objects: token, condition, stake, startTs, endTs, contentCID (IPFS). On-chain stores minimal metadata only.
- Support for any token: DexScreener coverage is required for price resolution. SDEX on-chain prices as fallback.
- Stakes: Prefer USDC via Stellar Asset Contract (SAC) for predictable payouts. Native XLM or other Stellar assets possible. Stakes are escrowed on-chain in the contract.
- Outcome model: Backend fetches price data at deadline, constructs a canonical message, signs with ed25519 key, and submits
submit_outcometo contract. Contract verifies signature and settles. - User experience: Low fees (~0.0001 XLM), fast finality (~5 seconds), seamless Freighter wallet integration.
- Simplicity & scalability: Keep on-chain logic minimal, index events off-chain, design oracle pipeline to scale from single signer → multiple signers.
- Security: Oracle keys stored in KMS/Vault; use multisig for admin actions in production.
flowchart TD
User(User) --> Frontend[Next.js Frontend]
Frontend --> Wallet[Freighter Wallet]
Wallet --> Contracts[Soroban Smart Contracts]
Contracts --> EventEmit[Emit Contract Events]
EventEmit --> Backend[NestJS Indexer Service]
Backend --> OracleWorker[Oracle Worker]
OracleWorker --> PriceSign[ed25519 Signed Outcome]
PriceSign --> Contracts
Contracts --> Result[Validate & Settle Stakes]
Result --> Frontend
sequenceDiagram
participant U as User (Next.js)
participant W as Freighter Wallet
participant C as call_registry (Soroban)
participant N as NestJS
participant IP as IPFS
participant O as OracleWorker
participant M as outcome_manager (Soroban)
U->>W: Sign createCall transaction
W->>C: create_call(...)
C-->>N: emit CallCreated (via SorobanRPC polling)
N->>IP: pin call content (CID)
N->>DB: store call record
Note over N,O: Wait until call.end_ts
O->>DexScreener: fetch final price
O->>KMS: sign ed25519 outcome message
O->>M: submit_outcome(call_id, outcome, signature)
M-->>N: emit OutcomeSubmitted
N->>DB: update call status, notify users
U->>W: withdraw_payout(call_id)
W->>M: withdraw_payout(...)
- Wallet / Auth: Freighter wallet connection via
@stellar/freighter-api; support Lobstr, Albedo as alternatives. - Token discovery: Proxy calls to backend
/tokens/searchwhich queries DexScreener and caches results. - Create Call UI: Title, thesis, token selector, condition builder (Target/Percent/Range), stake selector (USDC default). Upload thesis to IPFS.
- Transaction flow: Build transaction with
@stellar/stellar-sdk, sign via Freighter, submit to Stellar network. - Feeds & Threads: Pull from PostgreSQL via NestJS endpoints; live updates via WebSocket.
- Outcome provenance: Show final price, oracle public key, ed25519 signature and link to evidence (API response pinned to IPFS if required).
- AuthModule: Map Stellar public keys → user profiles.
- CallsModule: Create drafts, IPFS pinning, transaction building.
- OracleModule: Price fetcher, ed25519 signing, outcome submission.
- IndexerModule: SorobanRPC
getEventspolling + PostgreSQL writes. - NotificationModule: WebSocket server for live feed pushes (socket.io).
- AdminModule: Moderation, dispute handling.
- call_registry: Stores call records, manages stakes, emits
CallCreated. - outcome_manager: Verifies ed25519 signatures and settles outcomes; supports
withdraw_payout. - No Paymaster needed: Stellar's low fees (~0.0001 XLM) make gas sponsorship unnecessary.
- SorobanRPC: Poll
getEventsfor contract events (7-day retention limit). - Self-managed persistence: Store events immediately in PostgreSQL for permanent history.
- PostgreSQL: Main read model for UI and feeds; use
tsvectorfor full-text search. - IPFS: Store thesis content, avatars, comments. Pin via Pinata or self-hosted cluster.
create_call(...)— registers call metadata + escrows stakestake_on_call(call_id, amount, position)— other participants can join (YES/NO positions)- Emits
CallCreated,StakeAddedevents
submit_outcome(call_id, outcome, final_price, timestamp, public_key, signature)- Verifies ed25519 signature via
env.crypto().ed25519_verify() - Marks
settledand emitsOutcomeSubmitted withdraw_payout(call_id)— transfers winnings to participants
// packages/contracts-stellar/call_registry/src/lib.rs
#![no_std]
use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short,
Address, BytesN, Env, String,
token::TokenClient,
};
#[contracttype]
#[derive(Clone)]
pub struct Call {
pub creator: Address,
pub stake_token: Address,
pub total_stake_yes: i128,
pub total_stake_no: i128,
pub start_ts: u64,
pub end_ts: u64,
pub token_address: Address,
pub pair_id: BytesN<32>,
pub ipfs_cid: String,
pub settled: bool,
pub outcome: bool,
pub final_price: i128,
}
#[contracttype]
pub enum DataKey {
Call(u64),
NextCallId,
UserStake(u64, Address, bool), // (call_id, user, position)
Config,
}
#[contracttype]
#[derive(Clone)]
pub struct Config {
pub admin: Address,
pub outcome_manager: Address,
pub min_stake: i128,
pub platform_fee_bps: u32,
}
#[contract]
pub struct CallRegistry;
#[contractimpl]
impl CallRegistry {
/// Initialize the contract with admin and configuration
pub fn initialize(env: Env, admin: Address, outcome_manager: Address) {
admin.require_auth();
let config = Config {
admin,
outcome_manager,
min_stake: 1_000_000, // 0.1 USDC (7 decimals)
platform_fee_bps: 100, // 1%
};
env.storage().instance().set(&DataKey::Config, &config);
env.storage().instance().set(&DataKey::NextCallId, &0u64);
}
/// Create a new prediction call
pub fn create_call(
env: Env,
creator: Address,
stake_token: Address,
stake_amount: i128,
end_ts: u64,
token_address: Address,
pair_id: BytesN<32>,
ipfs_cid: String,
) -> u64 {
creator.require_auth();
let config: Config = env.storage().instance()
.get(&DataKey::Config)
.expect("Contract not initialized");
assert!(stake_amount >= config.min_stake, "Stake below minimum");
assert!(end_ts > env.ledger().timestamp(), "End time must be in future");
// Transfer stake to contract
let token = TokenClient::new(&env, &stake_token);
token.transfer(&creator, &env.current_contract_address(), &stake_amount);
// Get next call ID
let call_id: u64 = env.storage().instance()
.get(&DataKey::NextCallId)
.unwrap_or(0);
// Create call
let call = Call {
creator: creator.clone(),
stake_token,
total_stake_yes: stake_amount,
total_stake_no: 0,
start_ts: env.ledger().timestamp(),
end_ts,
token_address,
pair_id,
ipfs_cid,
settled: false,
outcome: false,
final_price: 0,
};
env.storage().persistent().set(&DataKey::Call(call_id), &call);
env.storage().instance().set(&DataKey::NextCallId, &(call_id + 1));
// Store user stake
env.storage().persistent().set(
&DataKey::UserStake(call_id, creator.clone(), true),
&stake_amount
);
// Emit event
env.events().publish(
(symbol_short!("CallCrtd"), call_id),
(creator, stake_amount, end_ts, token_address)
);
call_id
}
/// Add stake to an existing call
pub fn stake_on_call(
env: Env,
staker: Address,
call_id: u64,
amount: i128,
position: bool, // true = YES, false = NO
) {
staker.require_auth();
let mut call: Call = env.storage().persistent()
.get(&DataKey::Call(call_id))
.expect("Call not found");
assert!(env.ledger().timestamp() < call.end_ts, "Call has ended");
assert!(!call.settled, "Call already settled");
// Transfer stake
let token = TokenClient::new(&env, &call.stake_token);
token.transfer(&staker, &env.current_contract_address(), &amount);
// Update totals
if position {
call.total_stake_yes += amount;
} else {
call.total_stake_no += amount;
}
env.storage().persistent().set(&DataKey::Call(call_id), &call);
// Update or create user stake
let stake_key = DataKey::UserStake(call_id, staker.clone(), position);
let existing: i128 = env.storage().persistent()
.get(&stake_key)
.unwrap_or(0);
env.storage().persistent().set(&stake_key, &(existing + amount));
// Emit event
env.events().publish(
(symbol_short!("StakeAdd"), call_id),
(staker, position, amount)
);
}
/// Get call details (view function)
pub fn get_call(env: Env, call_id: u64) -> Call {
env.storage().persistent()
.get(&DataKey::Call(call_id))
.expect("Call not found")
}
}// packages/contracts-stellar/outcome_manager/src/lib.rs
#![no_std]
use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short,
Address, Bytes, BytesN, Env,
token::TokenClient,
};
#[contracttype]
pub enum DataKey {
AuthorizedOracle(BytesN<32>), // ed25519 public key
Settled(u64),
CallRegistry,
Admin,
}
#[contract]
pub struct OutcomeManager;
#[contractimpl]
impl OutcomeManager {
/// Initialize with admin and authorized oracle
pub fn initialize(
env: Env,
admin: Address,
call_registry: Address,
oracle_pubkey: BytesN<32>,
) {
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::CallRegistry, &call_registry);
env.storage().instance().set(&DataKey::AuthorizedOracle(oracle_pubkey), &true);
}
/// Add an authorized oracle
pub fn add_oracle(env: Env, oracle_pubkey: BytesN<32>) {
let admin: Address = env.storage().instance()
.get(&DataKey::Admin)
.expect("Not initialized");
admin.require_auth();
env.storage().instance().set(&DataKey::AuthorizedOracle(oracle_pubkey), &true);
}
/// Submit outcome with ed25519 signature verification
pub fn submit_outcome(
env: Env,
call_id: u64,
outcome: bool,
final_price: i128,
timestamp: u64,
public_key: BytesN<32>,
signature: BytesN<64>,
) {
// Check not already settled
let settled: bool = env.storage().persistent()
.get(&DataKey::Settled(call_id))
.unwrap_or(false);
assert!(!settled, "Already settled");
// Verify oracle is authorized
let is_authorized: bool = env.storage().instance()
.get(&DataKey::AuthorizedOracle(public_key.clone()))
.unwrap_or(false);
assert!(is_authorized, "Unauthorized oracle");
// Construct message to verify
// Format: "BACKit:Outcome:{call_id}:{outcome}:{final_price}:{timestamp}"
let message = Self::build_outcome_message(&env, call_id, outcome, final_price, timestamp);
// Verify ed25519 signature
env.crypto().ed25519_verify(&public_key, &message, &signature);
// Mark settled
env.storage().persistent().set(&DataKey::Settled(call_id), &true);
// Emit event
env.events().publish(
(symbol_short!("Outcome"), call_id),
(outcome, final_price, public_key)
);
}
/// Build canonical message for signature verification
fn build_outcome_message(
env: &Env,
call_id: u64,
outcome: bool,
final_price: i128,
timestamp: u64,
) -> Bytes {
// Construct canonical byte message
let mut message = Bytes::new(env);
// Prefix
message.append(&Bytes::from_slice(env, b"BACKit:Outcome:"));
// Call ID as bytes
message.append(&Self::u64_to_bytes(env, call_id));
message.append(&Bytes::from_slice(env, b":"));
// Outcome
message.append(&Bytes::from_slice(env, if outcome { b"1" } else { b"0" }));
message.append(&Bytes::from_slice(env, b":"));
// Final price
message.append(&Self::i128_to_bytes(env, final_price));
message.append(&Bytes::from_slice(env, b":"));
// Timestamp
message.append(&Self::u64_to_bytes(env, timestamp));
message
}
fn u64_to_bytes(env: &Env, val: u64) -> Bytes {
// Simple conversion for message construction
Bytes::from_slice(env, &val.to_be_bytes())
}
fn i128_to_bytes(env: &Env, val: i128) -> Bytes {
Bytes::from_slice(env, &val.to_be_bytes())
}
/// Withdraw payout for a settled call
pub fn withdraw_payout(env: Env, caller: Address, call_id: u64) {
caller.require_auth();
// Implementation: calculate share and transfer
// This would interact with call_registry to get call details
// and distribute winnings based on stake position
env.events().publish(
(symbol_short!("Payout"), call_id),
(caller,)
);
}
}Soroban provides three storage types with different characteristics:
| Storage Type | Characteristics | Use Cases | Limit |
|---|---|---|---|
| Instance | Loaded every call, shared TTL with contract | Config, admin address, oracle list | 64 KB total |
| Persistent | Per-key, archivable but restorable | Call data, user stakes | Per-entry limits |
| Temporary | Auto-deleted when TTL expires | Price cache, session data | Per-entry limits |
// Instance storage (loaded with every invocation)
env.storage().instance().set(&DataKey::Config, &config);
env.storage().instance().set(&DataKey::AuthorizedOracle(pubkey), &true);
// Persistent storage (archived after TTL, can be restored)
env.storage().persistent().set(&DataKey::Call(call_id), &call);
env.storage().persistent().set(&DataKey::UserStake(call_id, user, position), &amount);
// Temporary storage (deleted after TTL, NOT restorable)
env.storage().temporary().set(&DataKey::PriceCache(pair_id), &price);To prevent state archival, extend TTL proactively:
// Constants for TTL management
const INSTANCE_LIFETIME_THRESHOLD: u32 = 17280; // ~1 day in ledgers
const INSTANCE_BUMP_AMOUNT: u32 = 518400; // ~30 days in ledgers
// Extend instance storage TTL (includes all instance data)
env.storage().instance().extend_ttl(
INSTANCE_LIFETIME_THRESHOLD,
INSTANCE_BUMP_AMOUNT
);
// Extend specific persistent entry TTL
env.storage().persistent().extend_ttl(
&DataKey::Call(call_id),
INSTANCE_LIFETIME_THRESHOLD,
INSTANCE_BUMP_AMOUNT
);- Avoid unbounded data in instance storage — Instance storage is limited to 64KB and loaded with every call
- Use persistent for user data — Stakes and call data should be in persistent storage
- Extend TTL on interaction — When a user interacts with a call, extend its TTL
- Backend TTL automation — Run a cron job to extend TTL for active calls
- Native to Stellar ecosystem (all Stellar keys are ed25519)
- Fast and secure verification
- Direct verification via
env.crypto().ed25519_verify() - No additional libraries needed in contracts
The oracle constructs a deterministic message for signing:
// oracle.service.ts
import nacl from 'tweetnacl';
import { Keypair } from '@stellar/stellar-sdk';
function signOutcome(
callId: number,
outcome: boolean,
finalPrice: bigint,
timestamp: number
): { signature: Buffer; publicKey: Buffer } {
// Construct canonical message matching contract format
const callIdBytes = Buffer.alloc(8);
callIdBytes.writeBigUInt64BE(BigInt(callId));
const priceBytes = Buffer.alloc(16);
priceBytes.writeBigInt64BE(finalPrice, 8);
const timestampBytes = Buffer.alloc(8);
timestampBytes.writeBigUInt64BE(BigInt(timestamp));
const message = Buffer.concat([
Buffer.from('BACKit:Outcome:'),
callIdBytes,
Buffer.from(':'),
Buffer.from(outcome ? '1' : '0'),
Buffer.from(':'),
priceBytes,
Buffer.from(':'),
timestampBytes,
]);
// Sign with ed25519 private key (from KMS in production)
const keypair = Keypair.fromSecret(process.env.ORACLE_SECRET_KEY);
const signature = nacl.sign.detached(message, keypair.rawSecretKey());
return {
signature: Buffer.from(signature),
publicKey: keypair.rawPublicKey(),
};
}sequenceDiagram
participant O as OracleWorker
participant K as KMS/Vault
participant C as outcome_manager
O->>O: Build canonical message
O->>K: Sign message with ed25519 key
K-->>O: Return signature
O->>C: submit_outcome(call_id, outcome, price, ts, pubkey, sig)
C->>C: Rebuild message from parameters
C->>C: env.crypto().ed25519_verify(pubkey, message, sig)
C->>C: Mark settled, emit event
- Development: Store key in environment variable
- Production: Use HashiCorp Vault, AWS KMS, or GCP Cloud KMS
- Key rotation: Add new oracle pubkey → deauthorize old key → rotate
- Multisig future: Scale to requiring N-of-M signatures
/packages/backend
├── src/
│ ├── auth/ # Stellar public key → user profile mapping
│ ├── calls/ # Call creation, IPFS pinning
│ ├── oracle/ # Price fetching, ed25519 signing
│ ├── indexer/ # SorobanRPC event polling
│ ├── tokens/ # Token discovery, DexScreener proxy
│ ├── notifications/ # WebSocket push (socket.io)
│ └── admin/ # Moderation, disputes
// oracle/oracle.worker.ts
@Injectable()
export class OracleWorker {
@Cron('*/30 * * * * *') // Every 30 seconds
async processDueCalls() {
const dueCalls = await this.db.query(`
SELECT * FROM calls
WHERE end_ts <= NOW()
AND status = 'OPEN'
FOR UPDATE SKIP LOCKED
`);
for (const call of dueCalls) {
try {
// Fetch final price
const price = await this.fetchPrice(call.pair_id);
if (!price) {
await this.markUnresolved(call.id);
continue;
}
// Evaluate condition
const outcome = this.evaluateCondition(call.condition_json, price);
// Sign outcome
const { signature, publicKey } = this.signOutcome(
call.id, outcome, price, Date.now()
);
// Submit to contract
await this.submitOutcome(call, outcome, price, signature, publicKey);
// Update DB
await this.db.update(call.id, {
status: 'SETTLING',
final_price: price,
oracle_signature: signature.toString('hex'),
});
} catch (error) {
this.logger.error(`Failed to process call ${call.id}`, error);
}
}
}
}-
Primary: DexScreener
- Wide coverage including memecoins
- API:
https://api.dexscreener.com/latest/dex/pairs/stellar/{pairAddress}
-
Fallback: StellarX / SDEX
- Native Stellar DEX orderbook
- API: Horizon
GET /order_book?selling_asset=...&buying_asset=...
// lib/wallet.ts
import freighter from '@stellar/freighter-api';
export async function connectWallet(): Promise<string | null> {
try {
// Check if Freighter is installed
const isInstalled = await freighter.isConnected();
if (!isInstalled) {
window.open('https://freighter.app', '_blank');
return null;
}
// Request access
const hasAccess = await freighter.isAllowed();
if (!hasAccess) {
await freighter.setAllowed();
}
// Get public key
const publicKey = await freighter.getPublicKey();
return publicKey;
} catch (error) {
console.error('Wallet connection failed:', error);
return null;
}
}
export async function getNetwork(): Promise<string> {
const networkDetails = await freighter.getNetworkDetails();
return networkDetails.network; // 'TESTNET' or 'PUBLIC'
}// lib/transactions.ts
import {
SorobanRpc,
TransactionBuilder,
Networks,
Contract,
Address,
xdr,
} from '@stellar/stellar-sdk';
import freighter from '@stellar/freighter-api';
const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
export async function createCall(
callerPublicKey: string,
stakeToken: string,
stakeAmount: bigint,
endTs: number,
tokenAddress: string,
pairId: string,
ipfsCid: string,
): Promise<string> {
const account = await server.getAccount(callerPublicKey);
const contract = new Contract(CALL_REGISTRY_CONTRACT_ID);
const tx = new TransactionBuilder(account, {
fee: '100',
networkPassphrase: Networks.TESTNET,
})
.addOperation(
contract.call(
'create_call',
Address.fromString(callerPublicKey).toScVal(),
Address.fromString(stakeToken).toScVal(),
xdr.ScVal.scvI128(new xdr.Int128Parts({
hi: BigInt(stakeAmount >> 64n),
lo: BigInt(stakeAmount & 0xFFFFFFFFFFFFFFFFn),
})),
xdr.ScVal.scvU64(xdr.Uint64.fromBigInt(BigInt(endTs))),
Address.fromString(tokenAddress).toScVal(),
// ... other params
)
)
.setTimeout(30)
.build();
// Simulate to get resource estimate
const simulated = await server.simulateTransaction(tx);
const prepared = SorobanRpc.assembleTransaction(tx, simulated);
// Sign with Freighter
const signedXDR = await freighter.signTransaction(
prepared.toXDR(),
{ networkPassphrase: Networks.TESTNET }
);
// Submit
const result = await server.sendTransaction(
TransactionBuilder.fromXDR(signedXDR, Networks.TESTNET)
);
return result.hash;
}// Support multiple wallets
export type WalletType = 'freighter' | 'lobstr' | 'albedo';
export async function connectWallet(type: WalletType): Promise<string | null> {
switch (type) {
case 'freighter':
return connectFreighter();
case 'lobstr':
// LOBSTR wallet uses WalletConnect
return connectLobstrWC();
case 'albedo':
return connectAlbedo();
default:
throw new Error('Unknown wallet type');
}
}┌─────────────────┐ ┌──────────────────┐ ┌────────────┐
│ SorobanRPC │────▶│ IndexerService │────▶│ PostgreSQL │
│ getEvents() │ │ (NestJS) │ │ │
└─────────────────┘ └──────────────────┘ └────────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ WebSocket Push │
│ └──────────────────┘
│
▼
7-day retention limit
(must persist immediately)
// indexer/indexer.service.ts
import { SorobanRpc } from '@stellar/stellar-sdk';
@Injectable()
export class IndexerService {
private lastProcessedLedger: number = 0;
constructor(
private readonly db: DatabaseService,
private readonly ws: WebSocketGateway,
) {}
@Cron('*/5 * * * * *') // Every 5 seconds
async pollEvents() {
const server = new SorobanRpc.Server(process.env.SOROBAN_RPC_URL);
try {
const response = await server.getEvents({
startLedger: this.lastProcessedLedger || await this.getLatestLedger(),
filters: [
{
type: 'contract',
contractIds: [
process.env.CALL_REGISTRY_CONTRACT_ID,
process.env.OUTCOME_MANAGER_CONTRACT_ID,
],
},
],
pagination: { limit: 100 },
});
for (const event of response.events) {
await this.processEvent(event);
}
if (response.latestLedger) {
this.lastProcessedLedger = response.latestLedger;
await this.saveCheckpoint(response.latestLedger);
}
} catch (error) {
this.logger.error('Event polling failed', error);
}
}
private async processEvent(event: SorobanRpc.Api.EventInfo) {
const topic = event.topic[0]; // First topic is event name
switch (topic) {
case 'CallCrtd':
await this.handleCallCreated(event);
break;
case 'StakeAdd':
await this.handleStakeAdded(event);
break;
case 'Outcome':
await this.handleOutcome(event);
break;
case 'Payout':
await this.handlePayout(event);
break;
}
// Push to connected clients
this.ws.emitEvent(event);
}
}Important: SorobanRPC only retains events for 7 days (24 hours by default). Your indexer MUST persist events immediately.
Options:
- Self-managed (recommended): Poll every 5 seconds, store in PostgreSQL
- Mercury: Third-party indexing service (https://mercurydata.app)
- SubQuery: Decentralized indexing (https://subquery.network)
- Call thesis: Full text content, images, embedded media
- Oracle evidence: Price API responses at resolution time
- User profiles: Avatars, bios
// storage/ipfs.service.ts
import { PinataSDK } from 'pinata-web3';
@Injectable()
export class IpfsService {
private pinata: PinataSDK;
async pinCallContent(content: CallContent): Promise<string> {
const json = JSON.stringify({
title: content.title,
thesis: content.thesis,
condition: content.condition,
createdAt: new Date().toISOString(),
});
const result = await this.pinata.upload.json(JSON.parse(json));
return result.IpfsHash;
}
async pinOracleEvidence(evidence: OracleEvidence): Promise<string> {
const result = await this.pinata.upload.json({
callId: evidence.callId,
apiResponse: evidence.priceData,
timestamp: evidence.timestamp,
source: evidence.source,
});
return result.IpfsHash;
}
}- Use
require_auth()for all state-changing operations - Use
panic_with_error!instead of barepanic!for proper error codes - Validate all
Vec<T>andMap<K, V>inputs (can contain invalid data) - Avoid unbounded data in instance storage (64KB limit, loaded every call)
- Implement TTL extension for critical persistent data
- No reentrancy possible in Soroban (single-threaded execution)
- Test with fuzzing via
cargo fuzz
- Store ed25519 keys in KMS/Vault (never in code or env in production)
- Implement key rotation mechanism
- Log all signed messages with timestamps
- Pin price API evidence to IPFS for auditability
- Rate limit oracle submissions
- Validate all API inputs with class-validator
- Rate limit public endpoints
- Implement request timeouts for external APIs
- Use parameterized queries (prevent SQL injection)
- HTTPS only, proper CORS configuration
- Validate transaction parameters before signing
- Show clear transaction details to users
- Handle wallet disconnection gracefully
- Implement retry logic for failed transactions
| Failure Mode | Impact | Mitigation |
|---|---|---|
| Price API outage | Cannot resolve calls | Fallback to SDEX prices; mark UNRESOLVED if both fail |
| Oracle key compromise | Malicious outcomes | Pause contract via admin; rotate keys; audit logs |
| SorobanRPC unavailable | Cannot index events | Self-host Stellar Core + SorobanRPC; use multiple endpoints |
| Liquidity manipulation | Incorrect outcomes | Enforce minimum liquidity thresholds; use TWAP |
| State archival | Call data inaccessible | Proactive TTL extension; backend automation |
| Spam attacks | Resource exhaustion | Minimum stake requirement; rate limiting |
# Install Stellar CLI
cargo install stellar-cli --features opt
# Build contracts
stellar contract build
# Run tests
cargo test
# Deploy to testnet
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/call_registry.wasm \
--network testnet \
--source-account $STELLAR_SECRET_KEY
# Deploy SAC for USDC (mainnet issuer address)
stellar contract asset deploy \
--asset USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN \
--network mainnet \
--source-account $STELLAR_SECRET_KEY
# Initialize contract
stellar contract invoke \
--id $CALL_REGISTRY_CONTRACT_ID \
--network testnet \
--source-account $STELLAR_SECRET_KEY \
-- initialize \
--admin $ADMIN_ADDRESS \
--outcome_manager $OUTCOME_MANAGER_CONTRACT_ID
# Invoke function
stellar contract invoke \
--id $CALL_REGISTRY_CONTRACT_ID \
--network testnet \
-- get_call \
--call_id 1/packages/contracts-stellar
├── Cargo.toml
├── call_registry/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
├── outcome_manager/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── shared/
└── src/
└── lib.rs
# .github/workflows/contracts.yml
name: Soroban Contracts
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
with:
targets: wasm32-unknown-unknown
- name: Install Stellar CLI
run: cargo install stellar-cli --features opt
- name: Build contracts
run: stellar contract build
working-directory: packages/contracts-stellar
- name: Run tests
run: cargo test
working-directory: packages/contracts-stellar
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: wasm-contracts
path: target/wasm32-unknown-unknown/release/*.wasm- Soroban contract skeleton + basic tests
- Next.js + Freighter wallet integration demo
- NestJS skeleton + PostgreSQL schema
- IPFS pinning service setup
- Implement
call_registrycontract with tests - Implement
outcome_managercontract with tests - Deploy SAC for USDC on testnet
- Create Call UI + Freighter transaction signing
- SorobanRPC event indexer → PostgreSQL
- OracleWorker: DexScreener integration
- ed25519 key management (dev: env, prod: KMS)
- Relayer: submit_outcome transactions
- Withdraw payout flow
- Real-time notifications via WebSocket
- TTL management automation
- Liquidity threshold checks
- Moderation UI for admins
- Comprehensive testing + fuzzing
- Mainnet deployment preparation
- Security review
CREATE TABLE users (
stellar_address VARCHAR(56) PRIMARY KEY,
display_name TEXT,
avatar_cid TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE calls (
id SERIAL PRIMARY KEY,
call_onchain_id BIGINT UNIQUE,
creator_address VARCHAR(56) REFERENCES users(stellar_address),
ipfs_cid TEXT NOT NULL,
token_address VARCHAR(56),
pair_id TEXT,
stake_token VARCHAR(56),
total_stake_yes NUMERIC,
total_stake_no NUMERIC,
start_ts TIMESTAMPTZ,
end_ts TIMESTAMPTZ,
condition_json JSONB,
status VARCHAR DEFAULT 'OPEN', -- OPEN, SETTLING, RESOLVED, UNRESOLVED
outcome BOOLEAN,
final_price NUMERIC,
oracle_pubkey TEXT,
oracle_signature TEXT,
evidence_cid TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_calls_status ON calls(status);
CREATE INDEX idx_calls_end_ts ON calls(end_ts);
CREATE INDEX idx_calls_creator ON calls(creator_address);
CREATE TABLE participants (
id SERIAL PRIMARY KEY,
call_id INT REFERENCES calls(id),
stellar_address VARCHAR(56),
stake_amount NUMERIC,
position BOOLEAN, -- true = YES, false = NO
payout_claimed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_participants_call ON participants(call_id);
CREATE INDEX idx_participants_address ON participants(stellar_address);
CREATE TABLE events_log (
id SERIAL PRIMARY KEY,
ledger_sequence BIGINT,
contract_id TEXT,
topic TEXT,
event_data JSONB,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_events_ledger ON events_log(ledger_sequence);// Backend signature generation
interface OracleSignature {
callId: number;
outcome: boolean;
finalPrice: string; // Stringified bigint
timestamp: number; // Unix timestamp in seconds
publicKey: string; // Hex-encoded 32-byte ed25519 public key
signature: string; // Hex-encoded 64-byte ed25519 signature
}
// Message format (canonical byte representation)
// "BACKit:Outcome:" + callId(8 bytes BE) + ":" + outcome("0"|"1") + ":" + price(16 bytes BE) + ":" + timestamp(8 bytes BE)# .env.example
# Stellar Network
STELLAR_NETWORK=testnet # or 'mainnet'
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
HORIZON_URL=https://horizon-testnet.stellar.org
# Contract Addresses (set after deployment)
CALL_REGISTRY_CONTRACT_ID=CXXXXXX...
OUTCOME_MANAGER_CONTRACT_ID=CXXXXXX...
USDC_SAC_CONTRACT_ID=CXXXXXX...
# Oracle (use KMS in production)
ORACLE_SECRET_KEY=SXXXXXX...
# IPFS
PINATA_API_KEY=xxx
PINATA_SECRET_KEY=xxx
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/backit
# Redis
REDIS_URL=redis://localhost:6379
# Price APIs
DEXSCREENER_API_URL=https://api.dexscreener.comsequenceDiagram
participant U as User
participant F as Frontend (Next.js)
participant W as Freighter Wallet
participant IP as IPFS (Pinata)
participant CR as call_registry
participant I as Indexer (NestJS)
participant DB as PostgreSQL
participant O as OracleWorker
participant DS as DexScreener
participant OM as outcome_manager
Note over U,OM: === CREATE CALL ===
U->>F: Fill call form (token, condition, stake)
F->>IP: Pin thesis content
IP-->>F: Return CID
F->>F: Build transaction
F->>W: Request signature
W->>U: Approve transaction
U-->>W: Confirm
W->>CR: Submit create_call tx
CR->>CR: Validate, escrow stake
CR-->>I: Emit CallCreated event
I->>DB: Store call record
I->>F: Push notification via WebSocket
Note over U,OM: === WAIT FOR END TIME ===
Note over U,OM: === RESOLVE CALL ===
O->>DB: Query due calls
O->>DS: Fetch final price
DS-->>O: Price data
O->>IP: Pin price evidence
O->>O: Sign outcome (ed25519)
O->>OM: submit_outcome(...)
OM->>OM: Verify signature
OM-->>I: Emit OutcomeSubmitted
I->>DB: Update call status
I->>F: Push notification
Note over U,OM: === CLAIM PAYOUT ===
U->>F: Click "Claim Payout"
F->>W: Request signature
W->>OM: withdraw_payout tx
OM->>OM: Calculate share
OM->>U: Transfer winnings
OM-->>I: Emit Payout
This architecture document provides a complete, Stellar-native system design for BACKit. Key differentiators from EVM-based approaches:
- Native ed25519 signatures — No need for complex EIP-712 typed data
- Very low fees — No need for Paymaster/gas sponsorship
- Stellar Asset Contract (SAC) — Native USDC integration without bridges
- Soroban storage model — Three-tier storage with TTL management
- 5-second finality — Fast, deterministic transaction confirmation
For any questions or to request additional detail on specific components, please open an issue or reach out to the development team.