Capital-protected strategy vaults on Stellar. This repo contains:
- A Next.js frontend - vault dashboard, wallet connection, deposit/withdraw UI
- A Soroban smart contract - SEP-41 token + SEP-56 vault with on-chain strategy safety checks
The frontend connects to Stellar wallets and displays vault state. The smart contract lives on Soroban and handles all deposit/withdraw/strategy logic on-chain.
- Frontend: Next.js 15 / React 19 / TypeScript / Tailwind CSS / Stellar Wallets Kit
- Contract: Rust / Soroban SDK 26.1 /
wasm32v1-nonetarget - State management: Zustand + Immer for wallet state
- UI primitives: Radix UI (popover, tooltip, toggle)
src/
app/
page.tsx → Root page (redirects to vault)
layout.tsx → Root layout
vaults/
layout.tsx → Vaults layout
[vaultId]/page.tsx → Vault dashboard page
components/
stellar/ → Wallet provider, connect button, SSR guard
ui/ → Radix-based UI primitives (button, input, popover, etc.)
Footer.tsx → Footer component
stores/
useStellarWalletStore.ts → Zustand store (address, connected)
constants/
stellarVaults.ts → Vault definitions (name, asset, decimals, description)
lib/
utils.ts → Utility helpers
contracts/
strategy-vault/
src/
lib.rs → Public contract interface
vault.rs → Share math + multi-asset NAV (the chokepoint)
strategy.rs → Strategy execution + safeguards
events.rs → Onchain event schema (#[contractevent])
oracle.rs → Reflector (SEP-40) integration + circuit breaker
storage.rs → Storage keys, StrategyConfig, TTL helpers
errors.rs → Error enum
test.rs → 63 tests (~96% coverage)
Cargo.toml → Soroban SDK 26.1.0 + OZ Pausable
The contract implements two Stellar standards:
SEP-41 (Token) - vault shares are transferable tokens with balance, transfer, approve, transfer_from, burn, burn_from.
SEP-56 (Vault) - full vault interface: deposit, withdraw, redeem, mint, plus preview and conversion functions. Rounding favors the vault (down on deposit/redeem, up on withdraw/mint).
Strategy execution (execute_strategy) - operator-restricted trades guarded, in order, by: access control → nonce (strict, monotonic replay protection) → deadline (ledger timestamp) → token allowlist → router allowlist (only vetted DEX adapters) → trade-size cap → cooldown → swap → slippage (min_amount_out, operator floor) → oracle slippage cap (max_slippage_bps: realized output must clear the oracle-implied minimum, a hard bound the operator cannot loosen) → floor guardrail. Emits a strategy event with nav_before/nav_after.
Multi-asset NAV - total_assets() returns base_balance + Σ(risky_balanceᵢ × oracle_priceᵢ), valued in the base asset. It is the single chokepoint every conversion/preview funnels through, so share price reflects the whole portfolio. It is a live read of balances + oracle prices, never a stored number.
Oracle (Reflector, SEP-40) - get_safe_price computes the USD cross-rate from lastprice and reverts on a stale quote, an unavailable quote, or a deviation beyond deviation_bps between lastprice and the mean of recent prices() records (a deviating oracle can therefore never produce a trade). The vault never accepts an executor-supplied price. Reflector is the primary source in Tranche 1; a DEX-TWAP primary layer (Soroswap/Phoenix pools) lands with the DEX adapters in Tranche 2, demoting the external feed to a sanity check per the target architecture.
Floor guardrail - a strategy trade reverts if it would push the base-asset allocation below floor_bps of NAV (capital-protection floor, enforced at execution time).
Emergency pause (OZ Pausable) - a guardian (or admin) can pause/unpause. Pause halts deposit/mint/execute_strategy; withdraw/redeem stay callable.
User Exit Guarantee - withdraw/redeem read real onchain balances. When the vault holds only base (the keeper-maintained buffer), they always succeed regardless of strategy or oracle state.
Virtual offset - decimals_offset (config) hardens against share inflation / rounding attacks on empty vaults.
Fees - management (mgmt_fee_bps, per year) and performance (perf_fee_bps, on profit above a high-water mark) fees accrue via permissionless collect_fees(), paid by minting shares to the fee recipient (dilution). Both default to 0 until parameters are set per deployment.
| Event | Topics | Data |
|---|---|---|
deposit |
[deposit, from, receiver] |
[assets, shares] |
withdraw |
[withdraw, owner, receiver] |
[assets, shares] |
transfer |
[transfer, from, to] |
amount |
approve |
[approve, from, spender] |
[amount, expiration_ledger] |
burn |
[burn, from] |
amount |
strategy |
[strategy, operator] |
[nonce, token_in, token_out, amount_in, amount_out, nav_before, nav_after] |
paused / unpaused |
per OZ Pausable | - |
Note: there is intentionally no
circuit_brokenevent - the oracle halt is a revert, and Soroban rolls back events on revert, so the durable signal is theOracleDeviation/OracleStaleerror on the failed transaction.
max_trade_size (base units) · cooldown_period (s) · allowed_tokens (swap allowlist) · allowed_routers (venue allowlist) · max_slippage_bps (hard onchain cap vs oracle price) · floor_bps (min base % of NAV) · reflector_id (oracle) · asset_symbols (token → Reflector ticker) · deviation_bps · staleness (s) · decimals_offset · mgmt_fee_bps · perf_fee_bps. Risk parameters are set deliberately per deployment, not defaulted.
# Zero-warning wasm build (the OZ crates require stellar-cli ≥ 25.2.0)
cd contracts/strategy-vault && stellar contract build
# Coverage
cargo llvm-cov --summary-only
# Deploy to testnet
stellar contract deploy --wasm ../../target/wasm32v1-none/release/strategy_vault.wasm \
--source <identity> --network testnetPrerequisites: Node.js, pnpm
pnpm install
pnpm devOpen http://localhost:3000. The root page redirects to the vault dashboard.
Prerequisites: Rust (pinned via rust-toolchain.toml), wasm32v1-none target
# Add the WASM target (once) - rust-toolchain.toml also installs it automatically
rustup target add wasm32v1-none
# Run tests
cd contracts/strategy-vault
cargo test
# Build optimized WASM (zero warnings)
cargo build --target wasm32v1-none --releaseA single Railway-style service - Fastify API + embedded node-cron poller + Prisma + Postgres - that indexes the vault's onchain events and serves vault metrics to the dashboard.
- Ingest: each cycle pulls Soroban
getEventsfor the vault contract, decodes base64 XDR (scValToNative), and upserts on the event TOID (idempotent - re-ingest yields zero duplicates). Maintainsuser_positionfrom share-moving events. - Snapshots/metrics: reads
total_assets(NAV) /total_supply/ balances via RPC each cycle → TVL, allocation split, trailing share-price performance + APY. - API:
GET /vaults/:id/stats,/vaults/:id/history?range=,/users/:address/positions(p95 < 1s on indexed Postgres). - Dashboard:
src/app/vaults/[vaultId]/page.tsxreads this API (src/services/indexer.ts,src/hooks/useVaultData.ts) - TVL, allocation, and positions come from indexed data, not live RPC.
cd indexer
cp .env.example .env # set DATABASE_URL + VAULT_CONTRACT_ID
pnpm install
pnpm migrate:dev # create tables
pnpm dev # Fastify on :8080, polls every CRON_INTERVAL_SECONDSSet NEXT_PUBLIC_INDEXER_URL (default http://localhost:8080) for the frontend. Env vars: see indexer/.env.example.
| Layer | Technology |
|---|---|
| Framework | Next.js 15 |
| Language | TypeScript / Rust |
| Wallet | Stellar Wallets Kit (beta) |
| Styling | Tailwind CSS 3 |
| State | Zustand 5 + Immer |
| UI | Radix UI |
| Contract | Soroban SDK 26.1 + OZ Pausable |
| Indexer | Fastify + Prisma + Postgres + node-cron |
| Standards | SEP-41 (token), SEP-56 (vault), SEP-40 (oracle) |