Target Network: Stellar Soroban
Language: Rust (compiled to WebAssembly)
soroban-sdk Version: 22.0.0
Last Updated: May 29, 2026
YieldVault-RWA is a decentralized vault protocol built on Stellar's Soroban smart contracts. It enables users to deposit USDC and earn yield generated by tokenized real-world assets (RWAs) such as Korean sovereign debt instruments. The protocol implements:
- ERC-4626-style vault with fractional share minting (
yvUSDC) - Multi-strategy support via pluggable strategy connectors (BENJI, Korean Debt)
- DAO governance for strategy selection via weighted voting
- RWA shipment tracking for physical asset provenance
- Protocol fees with treasury accumulation
- Large-withdrawal timelocks for risk management
- Per-user deposit caps and minimum deposit thresholds
- Oracle price validation (infrastructure ready, not yet integrated)
| Module | File | Responsibility |
|---|---|---|
| YieldVault | contracts/vault/src/lib.rs |
Main vault contract; deposit/withdraw, yield accrual, strategy management, governance, shipment tracking |
| StrategyTrait | contracts/vault/src/strategy.rs |
Interface trait for strategy connectors |
| BenjiStrategy | contracts/vault/src/benji_strategy.rs |
Test-only BENJI fund token strategy connector |
| OracleValidator | contracts/vault/src/oracle.rs |
Standalone oracle price validation library (heartbeat, deviation, decimals) |
| MockKoreanSovereignStrategy | contracts/mock-strategy/src/lib.rs |
Test mock for Korean debt strategy with stepped yield curve |
| MockPriceOracle | contracts/mock-strategy/src/mock_oracle.rs |
Test mock oracle with configurable failure modes |
File: contracts/vault/src/lib.rs
Purpose: Core vault logic implementing ERC-4626 pattern with RWA-specific features
Initialization & Admin:
initialize(admin, token)— One-time initialization with admin and underlying tokenpropose_admin(new_admin)— Two-step admin transfer (propose)accept_admin()— Accept pending admin roleupgrade(new_wasm_hash)— WASM code upgrade (admin-only)
Deposit & Withdrawal:
deposit(user, amount) -> Result<i128, VaultError>— Mint shares for deposited tokenswithdraw(user, shares) -> Result<i128, VaultError>— Burn shares, return assets (may create timelock)execute_withdrawal(user) -> Result<i128, VaultError>— Complete pending large withdrawal after timelock
Strategy Management:
set_strategy(strategy)— Set active strategy (must be whitelisted)whitelist_strategy(strategy, approved)— Add/remove strategy from whitelistis_strategy_whitelisted(strategy) -> bool— Check if strategy is whitelistedstrategy() -> Option<Address>— Get active strategy addressinvest(amount)— Move idle funds to strategydivest(amount)— Recall funds from strategy
Yield & Accrual:
accrue_yield(amount)— Admin-initiated yield accrual (deducts protocol fee)report_benji_yield(strategy, amount)— BENJI strategy callback to report yieldaccrue_korean_debt_yield() -> i128— Harvest yield from Korean debt strategy
Governance (DAO):
set_dao_threshold(threshold)— Set voting threshold for proposalscreate_strategy_proposal(proposer, strategy) -> u32— Create strategy proposalvote_on_proposal(voter, proposal_id, support, weight)— Vote on proposalexecute_strategy_proposal(proposal_id)— Execute approved proposal (sets BenjiStrategy)
RWA Shipment Tracking:
add_shipment(shipment_id, status)— Add new shipment (admin-only)update_shipment_status(shipment_id, new_status)— Update shipment status (admin-only)shipment_ids_by_status(status, cursor, page_size) -> ShipmentPage— Paginated shipment query
Protocol Fees (Goal 1):
set_fee_bps(new_bps)— Set protocol fee in basis points (0–10000)fee_bps() -> i128— Get current fee rateset_treasury(treasury)— Set treasury addresstreasury() -> Option<Address>— Get treasury addresstreasury_balance() -> i128— Get accumulated fee balance
Large-Withdrawal Timelock (Goal 2):
set_large_withdrawal_threshold(threshold)— Set threshold for 24-hour timelocklarge_withdrawal_threshold() -> i128— Get current threshold
Minimum Deposit (Goal 3):
set_min_deposit(new_min)— Set minimum deposit amountmin_deposit() -> i128— Get current minimum
Oracle Configuration (Planned):
set_price_oracle(oracle)— Set oracle contract addressprice_oracle() -> Option<Address>— Get oracle addressset_oracle_enabled(enabled)— Enable/disable oracle validationis_oracle_enabled() -> bool— Check if oracle is enabledset_oracle_heartbeat(seconds)— Set oracle staleness thresholdoracle_heartbeat() -> u64— Get oracle heartbeat
Pause/Unpause:
pause()— Pause vault (blocks deposits/withdrawals)unpause()— Resume vaultis_paused() -> bool— Check pause status
Query Functions:
token() -> Address— Get underlying token addresstotal_shares() -> i128— Get total vault shares outstandingtotal_assets() -> i128— Get total assets (idle + strategy value)balance(user) -> i128— Get user's share balancecalculate_shares(assets) -> i128— Calculate shares for asset amountcalculate_assets(shares) -> i128— Calculate assets for share amountper_user_cap() -> i128— Get per-user deposit capuser_deposit(user) -> i128— Get user's total deposit amountbenji_strategy() -> Address— Get configured BENJI strategykorean_strategy() -> Address— Get configured Korean debt strategy
Core:
TokenAsset— Underlying token addressTotalShares— Total shares outstandingTotalAssets— Total idle assets in vaultAdmin— Current admin addressStrategy— Active strategy addressState— VaultState struct (total_shares, total_assets, is_paused)
Governance:
DaoThreshold— Voting threshold for proposalsProposalNonce— Counter for proposal IDsProposal(u32)— StrategyProposal struct by IDVote(u32, Address)— Vote record (proposal_id, voter)BenjiStrategy— Configured BENJI strategy addressKoreanDebtStrategy— Configured Korean debt strategy address
User State:
ShareBalance(Address)— User's share balanceUserDeposit(Address)— User's cumulative deposit amountPerUserCap— Per-user deposit capStrategyWhitelist(Address)— Strategy whitelist status
RWA Shipments:
ShipmentByStatus(ShipmentStatus)— List of shipment IDs by statusShipmentStatusOf(u64)— Current status of shipment
Protocol Fees:
FeeBps— Protocol fee in basis pointsTreasury— Treasury addressTreasuryBalance— Accumulated fee balance
Large Withdrawals:
LargeWithdrawalThreshold— Threshold for 24-hour timelockPendingWithdrawal(Address)— Pending withdrawal with unlock timestamp
Minimum Deposit:
MinDeposit— Minimum deposit amount
Oracle:
PriceOracle— Oracle contract addressOracleEnabled— Oracle validation enabled flagOracleHeartbeat— Oracle staleness threshold in seconds
| Event | Data | Emitted In |
|---|---|---|
(symbol_short!("deposit"),) |
(amount: i128, shares_minted: i128) |
deposit() |
(symbol_short!("withdraw"), user) |
(assets_to_return: i128, shares: i128) |
do_withdraw() |
(symbol_short!("pndwdraw"), user) |
(shares: i128, unlock_ts: u64) |
withdraw() — large withdrawal path |
(symbol_short!("feechg"),) |
(old_bps: i128, new_bps: i128) |
set_fee_bps() |
(symbol_short!("mindepchg"),) |
(old_min: i128, new_min: i128) |
set_min_deposit() |
pub enum VaultError {
AlreadyInitialized = 1, // initialize() called twice
InsufficientShares = 2, // withdraw() with more shares than user has
InvalidAmount = 3, // deposit/withdraw with amount <= 0
ContractPaused = 4, // deposit/withdraw while paused
ExceedsUserCap = 5, // deposit exceeds per-user cap
MinDepositNotMet = 6, // deposit below minimum
TimelockNotExpired = 7, // execute_withdrawal() before timelock
NoPendingWithdrawal = 8, // execute_withdrawal() with no pending
}| Target | Method | Called In | Purpose |
|---|---|---|---|
TokenAsset (SAC) |
transfer(from, to, amount) |
deposit(), do_withdraw(), accrue_yield(), report_benji_yield() |
Token transfers |
TokenAsset (SAC) |
approve(owner, spender, amount, expiry) |
invest() |
Approve strategy to spend |
Strategy |
total_value() |
total_assets() |
Get strategy value |
Strategy |
deposit(amount) |
invest() |
Deposit to strategy |
Strategy |
withdraw(amount) |
divest() |
Withdraw from strategy |
KoreanDebtStrategy |
harvest_yield() |
accrue_korean_debt_yield() |
Harvest Korean debt yield |
┌─────────────────────────────────────────────────────────────────┐
│ YieldVault │
│ (Main vault contract - deposit/withdraw/governance/shipments) │
└──────────────┬──────────────────────────────────────────────────┘
│
┌──────┴──────┬──────────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ TokenSAC│ │ Strategy │ │ BENJI │ │ KoreanDebt │
│ (USDC) │ │ (Active) │ │ Strategy │ │ Strategy │
└─────────┘ └──────────┘ └──────────┘ └──────────────┘
│ │ │ │
└─────────────┴──────────────┴──────────────┘
│
▼
┌──────────────────┐
│ Oracle (Future) │
│ Price Validator │
└──────────────────┘
Call Flow:
- User deposits USDC → YieldVault calls
TokenSAC.transfer()to receive tokens - Admin invests → YieldVault calls
Strategy.deposit()to move funds - Strategy yields → Strategy calls
YieldVault.report_benji_yield()to report yield - Admin accrues yield → YieldVault calls
KoreanDebtStrategy.harvest_yield()to get yield - User withdraws → YieldVault calls
TokenSAC.transfer()to return tokens (may divest from strategy)
For full request-to-confirmation sequence diagrams covering both deposit and withdrawal paths (including the 24-hour timelock flow), see Deposit & Withdrawal Lifecycle.
User Deposit Flow:
─────────────────
1. User calls vault.deposit(user, 100 USDC)
├─ Check: amount > 0, not paused, meets min_deposit
├─ Check: user_deposit + 100 <= per_user_cap
├─ Calculate: shares_to_mint = 100 * total_shares / total_assets
│ (or 100 if first deposit)
├─ Effect: Update state
│ ├─ total_assets += 100
│ ├─ total_shares += shares_to_mint
│ ├─ user_balance[user] += shares_to_mint
│ └─ user_deposit[user] += 100
├─ Interaction: TokenSAC.transfer(user, vault, 100)
└─ Event: emit ("deposit", (100, shares_to_mint))
2. User calls vault.withdraw(user, shares_to_mint)
├─ Check: shares > 0, user_balance[user] >= shares, not paused
├─ Calculate: assets_to_return = shares * total_assets / total_shares
├─ Check: if assets_to_return > large_withdrawal_threshold
│ └─ Create pending withdrawal with 24-hour timelock
│ └─ Event: emit ("pndwdraw", user, (shares, unlock_ts))
│ └─ Return 0 (user must call execute_withdrawal later)
├─ Otherwise (normal withdrawal):
│ ├─ Effect: Update state
│ │ ├─ total_assets -= assets_to_return
│ │ ├─ total_shares -= shares
│ │ ├─ user_balance[user] -= shares
│ │ └─ user_deposit[user] -= assets_to_return (capped at 0)
│ ├─ Interaction: TokenSAC.transfer(vault, user, assets_to_return)
│ └─ Event: emit ("withdraw", user, (assets_to_return, shares))
└─ Return: assets_to_return
3. (If timelocked) User calls vault.execute_withdrawal(user)
├─ Check: pending withdrawal exists, unlock_timestamp <= now
├─ Effect: Remove pending withdrawal, update state as above
└─ Interaction: TokenSAC.transfer(vault, user, assets_to_return)
All vault state is stored in instance storage (persistent across contract calls):
- Admin & Initialization:
Admin,Initialized,PendingAdmin - Core Vault:
TokenAsset,TotalShares,TotalAssets,State - Strategy:
Strategy,StrategyWhitelist(Address),BenjiStrategy,KoreanDebtStrategy - User Balances:
ShareBalance(Address),UserDeposit(Address) - Governance:
DaoThreshold,ProposalNonce,Proposal(u32),Vote(u32, Address) - Shipments:
ShipmentByStatus(ShipmentStatus),ShipmentStatusOf(u64) - Fees & Treasury:
FeeBps,Treasury,TreasuryBalance - Withdrawals:
LargeWithdrawalThreshold,PendingWithdrawal(Address) - Deposits:
MinDeposit,PerUserCap - Oracle:
PriceOracle,OracleEnabled,OracleHeartbeat - Pause:
IsPaused(stored inStatestruct)
Soroban instance storage has a default TTL of ~6 months. The vault does not explicitly extend TTL — it relies on Soroban's automatic extension on contract invocation. For production, consider:
- Periodic "heartbeat" transactions to extend TTL
- Monitoring TTL expiration via ledger state
- User-keyed storage:
ShareBalance(Address),UserDeposit(Address),Vote(u32, Address),PendingWithdrawal(Address)— allows efficient per-user queries - Status-keyed storage:
ShipmentByStatus(ShipmentStatus)— enables filtering by status - Proposal-keyed storage:
Proposal(u32)— indexed by proposal ID
For a comprehensive threat model covering trust assumptions, trust boundaries, attack surface, and categorized threat scenarios, see Threat Model & Trust Boundaries.
| Function | Required Auth | Notes |
|---|---|---|
initialize |
None | One-time, sets admin |
upgrade |
Admin | WASM upgrade |
propose_admin / accept_admin |
Admin / PendingAdmin | Two-step transfer |
set_strategy |
Admin | Must be whitelisted |
whitelist_strategy |
Admin | |
pause / unpause |
Admin | |
configure_korean_strategy |
Admin | |
accrue_korean_debt_yield |
Admin | |
set_dao_threshold |
Admin | |
add_shipment / update_shipment_status |
Admin | |
accrue_yield |
Admin | |
invest / divest |
Admin | |
set_fee_bps / set_treasury |
Admin | |
set_large_withdrawal_threshold |
Admin | |
set_min_deposit |
Admin | |
set_price_oracle / set_oracle_enabled / set_oracle_heartbeat |
Admin | |
create_strategy_proposal |
Proposer (signed) | Any user can propose |
vote_on_proposal |
Voter (signed) | Any user can vote |
execute_strategy_proposal |
Public | Anyone can execute |
report_benji_yield |
Configured BenjiStrategy | Only registered strategy |
deposit |
User (signed) | User must sign |
withdraw / execute_withdrawal |
User (signed) | User must sign |
| Query functions | Public | No auth required |
Soroban's Atomic Model:
- Contract calls are atomic within a transaction
- State changes are committed atomically
- No recursive calls can occur during execution
- Each contract invocation gets its own execution frame
CEI Pattern (Checks-Effects-Interactions): All state-changing functions follow CEI:
- Checks: Validate inputs, auth, preconditions
- Effects: Update contract state
- Interactions: Make external calls (token transfers, strategy calls)
Example from deposit():
Checks:
- amount > 0
- not paused
- meets min_deposit
- user_deposit + amount <= cap
Effects:
- Update total_assets, total_shares, user_balance, user_deposit
Interactions:
- TokenSAC.transfer(user, vault, amount)
- Two-step admin transfer:
propose_admin()+accept_admin()prevents accidental loss - Pause mechanism: Admin can pause vault to stop deposits/withdrawals during emergencies
- Strategy whitelist: Admin controls which strategies can be set
- Governance threshold: Admin sets DAO voting threshold
- Upgrade checklist: every upgrade should preserve storage versioning, validate proxy admin/auth state, and verify storage layouts before deployment
- Strategy sanity checks: rebalance operations reject self-reallocation and negative slippage bounds to avoid accidental fund movement
- Amount validation: All amounts checked for > 0 (or >= 0 for min_deposit)
- Overflow protection: All arithmetic uses
checked_*methods - Share rounding: Deposits that would mint 0 shares are rejected (prevents silent loss)
- Threshold validation: Large-withdrawal threshold must be > 0
- Create module file:
contracts/vault/src/my_module.rs - Declare in lib.rs:
pub mod my_module; // or #[cfg(test)] mod my_module; for test-only
- Define public interface:
pub fn my_function(env: Env, param: Type) -> Result<ReturnType, MyError>
- Add storage keys if needed:
// In DataKey enum in lib.rs MyModuleKey(Address),
- Document with RustDoc:
/// Brief description of what this does. /// /// ### Parameters /// * `param` - What this parameter does /// /// ### Returns /// What is returned /// /// ### Errors /// When errors occur pub fn my_function(...)
# Run all tests
cargo test
# Run specific test
cargo test test_deposit_works
# Run with output
cargo test -- --nocapture
# Run fuzz tests (10,000 iterations)
cargo test fuzz_deposit_withdraw_symmetry_no_fee
# Run libFuzzer share-price harness (60s smoke)
cd vault
cargo install cargo-fuzz
cargo +nightly fuzz run share_price_math -- -max_total_time=60
# Run security tests
cargo test --test security_tests# Build optimized WASM
cargo build --target wasm32-unknown-unknown --release
# Optimize further
soroban contract optimize --wasm target/wasm32-unknown-unknown/release/vault.wasm
# Generate docs
cargo doc --no-deps --openSee contracts/vault/DEPLOYMENT.md for step-by-step deployment procedures.
YieldVault emits cryptographically-signed events for all critical operations. These events are published to the Stellar blockchain and can be consumed by off-chain indexers, backend services, and user notification systems.
| Event | Emitted By | When | Topics | Data |
|---|---|---|---|---|
deposit |
deposit() |
User deposits USDC | contract_id | (amount, shares_minted) |
pndwdraw |
withdraw() |
Large withdrawal initiated (timelocked) | contract_id, user | (shares, unlock_timestamp) |
withdraw |
withdraw() / execute_withdrawal() |
Withdrawal completes | contract_id, user | (assets_returned, shares_burned) |
feechg |
set_fee_bps() |
Protocol fee updated | contract_id | (old_bps, new_bps) |
mindepchg |
set_min_deposit() |
Minimum deposit updated | contract_id | (old_min, new_min) |
deposit Event
- Emitted: When user successfully deposits USDC
- Data:
(amount: i128, shares_minted: i128) - Use Case: Track user deposits, update analytics, trigger notifications
pndwdraw Event
- Emitted: When withdrawal exceeds
large_withdrawal_threshold(24-hour timelock) - Data:
(shares: i128, unlock_timestamp: u64) - Topics: Includes user address for filtering
- Use Case: Notify user of pending withdrawal, track timelock expiry
withdraw Event
- Emitted: When withdrawal completes (either immediately or after timelock)
- Data:
(assets_returned: i128, shares_burned: i128) - Topics: Includes user address for filtering
- Use Case: Track user withdrawals, update balances, trigger notifications
feechg Event
- Emitted: When admin updates protocol fee
- Data:
(old_bps: i128, new_bps: i128) - Use Case: Update fee configuration, alert on significant changes
mindepchg Event
- Emitted: When admin updates minimum deposit threshold
- Data:
(old_min: i128, new_min: i128) - Use Case: Update deposit validation, alert on threshold changes
For a complete guide on consuming YieldVault events, see Webhook Integration Guide.
The guide includes:
- Event Catalog — Detailed documentation of all events
- Setup Instructions — Step-by-step consumer setup in TypeScript, Python, and Rust
- Signature Verification — How to verify event authenticity
- Retry Strategies — Handling RPC unavailability and missed events
- Error Handling — Common failure scenarios and recovery
- Security Best Practices — Protecting against replayed/spoofed events
- Complete Examples — Production-ready consumer implementations
- Immutability: Events are immutable once published to the ledger
- Ordering: Events are always returned in ledger order
- Deduplication: Use cursor-based pagination to avoid missing events
- Verification: Verify event source by checking contract address and ledger sequence
- Replay Protection: Detect replayed events by hashing event data
-
Oracle not integrated:
oracle.rsmodule is ready but not wired into vault logic- Functions
set_price_oracle(),is_oracle_enabled(),oracle_heartbeat()exist but are not used - Strategy value validation against oracle is planned but not implemented
- Functions
-
Single active strategy: Only one strategy can be active at a time
- Future: Support multiple strategies with allocation percentages
-
No strategy performance fees: Strategies don't take a cut of yield
- Future: Add strategy fee configuration
-
Shipment tracking is basic: No integration with external RWA provenance systems
- Future: Connect to Stellar Attestation Service or similar
-
No emergency withdrawal: Users cannot withdraw during pause
- Future: Add emergency withdrawal with reduced share price
- Oracle price validation integration
- Multi-strategy allocation
- Strategy performance fees
- Emergency withdrawal mechanism
- Yield distribution events
- Cross-chain bridge support
| File | Coverage | Tests |
|---|---|---|
src/test.rs |
Core vault logic | 50+ tests covering deposit, withdraw, governance, shipments, invariants |
src/fuzz_math.rs |
Math safety | 10,000+ property-based tests for overflow, monotonicity, round-trip |
src/oracle_tests.rs |
Oracle validation | 10+ tests for price data, heartbeat, deviation |
src/event_tests.rs |
Event emission | 5+ tests for event correctness |
src/proxy_tests.rs |
Upgrade & storage | 4+ tests for initialization, upgrade, storage layout |
tests/security_tests.rs |
Security patterns | Checklist-based security test stubs |
- Share price consistency:
total_assets / total_sharesratio never changes unexpectedly - Full exit zeroes state: When all users withdraw all shares, state reaches 0
- Sum of balances: Total shares = sum of all user balances
- Yield never changes shares: Yield accrual only increases total_assets
- No value extraction: Deposit → withdraw never returns more than deposited
- Overflow safety: All arithmetic operations are safe
-
cargo checkpasses with zero warnings -
cargo testpasses all suites -
cargo doc --no-depsgenerates without errors - All public functions have RustDoc comments
- All storage keys are documented
- All events are documented
- All error cases are tested
- Security checklist reviewed (see
docs/SECURITY_CHECKLIST.md)
- Monitor vault pause status
- Track strategy allocation and yield accrual
- Monitor large withdrawal timelocks
- Track protocol fee accumulation
- Monitor oracle staleness (when integrated)
- Track governance proposal activity
See contracts/vault/DEPLOYMENT.md and docs/runbooks/CONTRACT_UPGRADE_PLAYBOOK.md for detailed upgrade runbook including:
- Build and optimize new WASM
- Pause vault (safety check)
- Install new WASM and get hash
- Execute upgrade
- Verify version
- Resume operations
- Soroban SDK: https://github.com/stellar/rs-soroban-sdk
- ERC-4626 Standard: https://eips.ethereum.org/EIPS/eip-4626
- Stellar Docs: https://developers.stellar.org/
- Threat Model:
docs/THREAT_MODEL.md - Formal Verification Notes:
docs/FORMAL_VERIFICATION_ACCOUNTING.md - Deployment Guide:
contracts/vault/DEPLOYMENT.md - Security Checklist:
docs/SECURITY_CHECKLIST.md - False Positives:
contracts/.false-positives.md
Document Version: 1.0
Created: May 29, 2026
Maintainers: YieldVault Development Team
The following production-hardening changes were applied to admin-only functions:
Previously used assert!(initiator == primary, "only primary approver can initiate"), which caused an uncontrolled contract panic on mainnet. Changed to return Err(VaultError::UnauthorizedCaller) (code 50). The function return type changed from u32 to Result<u32, VaultError>.
Three assert! guards replaced with proper Result error returns:
confirmer != secondary→Err(VaultError::UnauthorizedCaller)proposal.executed→Err(VaultError::ProposalAlreadyExecuted)proposal.initiator == confirmer→Err(VaultError::UnauthorizedCaller)
Added #[cfg(test)] so this test helper is excluded from the compiled WASM and cannot be called on mainnet.
New error variant (code 50) added to the stable error namespace. Integrators should map this code per docs/api/ERROR_CODE_CATALOG.md.
contracts/vault/tests/access_control_test.rs — covers all admin-only functions and the hardened emergency-action paths.