┌──────────────────────────────────────────────────────────────┐
│ User (Browser) │
└───────────────────────────┬──────────────────────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────────────────────┐
│ Next.js Frontend │
│ (frontend/app/) │
└───────────────────────────┬──────────────────────────────────┘
│ REST / JSON
▼
┌──────────────────────────────────────────────────────────────┐
│ Express API Server │
│ (server/src/) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Middleware │ │ Routes │ │ Services │ │
│ │ │ │ │ │ │ │
│ │ helmet │ │ GET /health │ │ stellar.ts │ │
│ │ cors │ │ GET /trades│ │ ├─ createListing │ │
│ │ morgan │ │ POST /trades│ │ └─ depositToEscrow │ │
│ │ express. │ │ GET /:id │ │ │ │
│ │ json() │ │ POST /:buy │ │ │ │
│ │ authenticate│ │ │ │ │ │
│ └─────────────┘ └──────┬───────┘ └──────────┬──────────┘ │
│ │ │ │
└──────────────────────────┼──────────────────────┼────────────┘
│ │
┌────────────┘ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────────┐
│ PostgreSQL │ │ Stellar Network │
│ │ │ │
│ users │ │ ┌────────────────────┐ │
│ wallets │ │ │ Horizon RPC │ │
│ trade_offers │ │ │ (account loads, │ │
│ │ │ │ tx submission) │ │
└─────────────────────┘ │ └────────────────────┘ │
│ ┌────────────────────┐ │
┌────────────────────┤ │ Soroban RPC │ │
│ │ │ (contract calls, │ │
▼ │ │ tx simulation) │ │
┌─────────────────────┐ │ └────────────────────┘ │
│ Paystack │ │ ┌────────────────────┐ │
│ (fiat payments) │ │ │ Escrow Contract │ │
└─────────────────────┘ │ │ create_listing │ │
│ │ deposit_to_escrow │ │
┌─────────────────────┐ │ │ release_payment │ │
│ Termii │ │ │ cancel_and_refund │ │
│ (OTP via SMS) │ │ └────────────────────┘ │
└─────────────────────┘ └──────────────────────────┘
Client
│
│ POST /api/v1/trades
│ Authorization: Bearer <jwt>
│ { assetType, amount, expiresInHours }
│
▼
authenticate middleware
│ verify JWT → extract { sub, stellarPublicKey }
│
▼
trades router (POST /)
│ validate body with Zod
│ fetch stellar_secret_key from wallets table
│
▼
stellar.createListing()
│ build Soroban transaction
│ simulate via Soroban RPC
│ sign with seller keypair
│ submit to Soroban RPC
│ poll until confirmed
│ return contract trade ID
│
▼
PostgreSQL INSERT into trade_offers
│ id, seller_id, asset_type, amount, status='Active',
│ contract_listing_id, expires_at
│
▼
Response 201 { data: TradeOffer }
Client
│
│ POST /api/v1/trades/:id/buy
│ Authorization: Bearer <jwt>
│ { buyerSecretKey }
│
▼
authenticate middleware
│ verify JWT → extract { sub, stellarPublicKey }
│
▼
trades router (POST /:id/buy)
│ fetch trade from PostgreSQL
│ guard: status === 'Active'
│ guard: buyer !== seller
│
▼
stellar.depositToEscrow()
│ build Soroban transaction calling deposit_to_escrow
│ simulate → sign → submit
│ poll until confirmed
│ return escrow tx hash
│
▼
PostgreSQL UPDATE trade_offers
│ SET status='Locked', buyer_id, escrow_tx_hash
│
▼
Response 200 { data: TradeOffer }
Server entry point. Loads env, validates required vars, registers middleware, mounts routes, starts the HTTP listener.
All /api/v1/trades route handlers. Zod validation, business logic, async error
wrapping. Delegates blockchain calls to services/stellar.ts and DB queries
to the pool in db.ts.
Validates Authorization: Bearer <jwt> headers. Decodes the token and attaches
{ sub, stellarPublicKey } to req.user. Returns 401 on failure.
Wraps @stellar/stellar-sdk. Builds, simulates, signs, and submits Soroban
transactions. Polls Soroban RPC until transactions reach a terminal state.
Two exported functions: createListing and depositToEscrow.
Shared pg.Pool (max 10 connections). All DB queries go through this pool.
TypeScript interface TradeOffer and union type TradeStatus matching the
PostgreSQL trade_offers table schema.
Soroban smart contract. Manages on-chain trade state, token custody, authorization, and event emission.
Why PostgreSQL alongside the blockchain? The Soroban contract stores minimal on-chain state (trade IDs, amounts, addresses). The database holds the rest (user profiles, phone numbers, wallet keys, full trade history) and enables efficient queries that would be expensive on-chain.
Why is the admin the only one who can call release_payment?
P2P airtime delivery can't be cryptographically verified on-chain. The platform
acts as an oracle — it verifies delivery through a separate channel (e.g. SMS
delivery receipt from Termii) and then triggers payment release.
Why Stellar for settlement? Low fees (~0.00001 XLM per operation), 5-second finality, and native stablecoin support (USDC via Circle, NGNC via Convexity) make it well-suited for micro-value telecom transactions.
Why Soroban for escrow instead of a simple multisig? Soroban allows programmable release conditions, event emission for indexing, and extensibility (future: dispute voting, time-weighted release, etc.).