This document describes the implementation of revenue settlement functionality that allows the vault contract to automatically transfer USDC to a settlement contract when deductions occur. The settlement contract then credits either a global pool or specific developer balances.
The integration between the vault and settlement contracts ensures that tracked balances stay in sync across both systems. Here's how it works:
- Atomic Operations: All operations (validation → token transfer → settlement contract call → state update) happen atomically. If any step fails, the entire transaction reverts with no partial state changes.
- Reconciliation Flow:
- The vault contract first validates the deduct/batch-deduct request
- It transfers USDC tokens to the settlement contract
- It calls
settlement_client.receive_payment(..., to_pool=true, developer=None)to notify the settlement contract to credit the global pool - Only after the cross‑contract call succeeds does the vault update its own internal balance
to_poolSemantics: For all vault‑originated deducts and batch deducts, the deducted amount is always credited to the global pool in the settlement contract.
-
Vault Contract (
callora-vault)- Enhanced with settlement contract integration
- Automatically transfers USDC to settlement on
deduct()andbatch_deduct() - Maintains settlement contract address configuration
- Uses cross‑contract calls to
settlement_client.receive_payment()to ensure reconciliation
-
Settlement Contract (
callora-settlement)- Receives USDC payments from vault
- Credits global pool or specific developer balances
- Provides comprehensive access control
sequenceDiagram
participant API as API Client
participant Vault as Vault Contract
participant USDC as USDC Contract
participant Settlement as Settlement Contract
API->>Vault: deduct(env, caller, amount, request_id)
Vault->>Vault: Validate Auth & Balance
Vault->>USDC: transfer(vault, settlement, amount)
USDC-->>Vault: Transfer complete
Vault->>Settlement: receive_payment(vault, amount, to_pool=true, developer=None)
Settlement->>Settlement: Validate caller (vault)
Settlement->>Settlement: Update Global Pool
Settlement-->>Vault: Payment successful
Vault->>Vault: Update internal balance & mark request processed
Vault-->>API: Return new balance
StorageKey::Settlement // Primary routing address (highest priority)
StorageKey::RevenuePool // Fallback routing address (used if Settlement not set)-
set_settlement(env, caller, settlement_address)(Admin only)- Sets the settlement contract address (primary routing destination)
- Authorization: Current admin only
- Validation: Address cannot be the vault's own address
- Panic: "unauthorized: caller is not admin" or "cannot route to vault itself"
- Event:
set_settlement(admin) → address
-
get_settlement(env)(Public read-only)- Returns the configured settlement contract address
- Read-only: No state mutation, safe for indexers
- Panic: "settlement address not set" if not configured
-
set_revenue_pool(env, caller, revenue_pool)(Admin only)- Sets the revenue pool contract address (fallback routing destination)
- Authorization: Current admin only
- Validation: Address cannot be the vault's own address
- Can be set to
Noneto clear the configuration - Events:
set_revenue_pool(admin) → addressorclear_revenue_pool(admin) → ()
-
get_revenue_pool(env)(Public read-only)- Returns the configured revenue pool address (Option)
- Read-only: No state mutation, safe for indexers
- Returns
Noneif not configured (does not panic)
CRITICAL: The vault enforces that the settlement address MUST be configured before any deduct operation can succeed. This is validated via require_settlement() which is consulted by both deduct() and batch_deduct().
- If
settlementis not configured: PANIC with"settlement address not set"and the transaction reverts with no state change. - This prevents silent loss-of-accounting where the vault's internal
balancecould drift from the on-ledger USDC balance. - The settlement address is validated at configuration time to prevent self-referential routing (vault → vault).
Every deduct / batch_deduct call routes the deducted USDC to the configured settlement address. revenue_pool is not consulted during deducts; it is retained as an informational configuration slot only.
settlementset → funds transferred to settlement contract.settlementunset → deduct panics with"settlement address not set", no balance change, no event emitted.
-
deduct(env, caller, amount, request_id)- Added automatic transfer to settlement contract if
StorageKey::Settlementis set - Flow: Validate → Update balance → Transfer bounds (transfer_funds) → Emit
deductevent usingrequest_id
- Added automatic transfer to settlement contract if
-
batch_deduct(env, caller, items)- Added automatic transfer of total amount to settlement
- Calculates total batch amount for settlement transfer
- Maintains atomic batch operation
pub struct DeveloperBalance {
pub address: Address,
pub balance: i128,
}
pub struct GlobalPool {
pub total_balance: i128,
pub last_updated: u64,
}
pub struct PaymentReceivedEvent {
pub from_vault: Address,
pub amount: i128,
pub to_pool: bool,
pub developer: Option<Address>,
}
pub struct BalanceCreditedEvent {
pub developer: Address,
pub amount: i128,
pub new_balance: i128,
}-
init(env, admin, vault_address)- Initializes settlement contract with admin and vault addresses
- Creates empty developer balances and global pool
- Panic: "settlement contract already initialized"
-
set_usdc_token(env, caller, usdc_address)- Configures the USDC token contract address for withdrawals
- Authorization: Current admin only
- Validation: Token address cannot be the contract itself
- Panic: "unauthorized: caller is not admin" or "invalid config: usdc_token cannot be the contract itself"
-
receive_payment(env, caller, amount, to_pool, developer)- Access Control: Only vault or admin can call
- Validation: Amount must be positive
- Pool Credit: If
to_pool=true, credits global pool - Developer Credit: If
to_pool=false, requires developer address - Events:
PaymentReceivedEventfor all paymentsBalanceCreditedEventfor developer credits
-
withdraw_developer_balance(env, developer, amount)- Access Control: Only the developer may call
- Validation: Amount must be positive and cannot exceed tracked balance
- Token Flow: Transfers USDC from the settlement contract to the developer
- State Update: Deducts the withdrawn amount from the tracked balance using checked arithmetic
- Events:
DeveloperWithdrawEventafter transfer succeeds
-
Query Functions
get_admin(),get_vault(),get_global_pool()get_developer_balance(developer)get_all_developer_balances()(admin only, safe only for <=100 developers)get_developer_balances_page(start, limit)(admin only, paginated)
-
Admin Functions
set_admin()(admin only)set_vault()(admin only)
- Vault Authorization: Only registered vault address can call
receive_payment() - Admin Override: Admin can also call
receive_payment()for emergency operations - Settlement Address Control: Only admin can configure settlement address in vault
- Contract Initialization: Single initialization to prevent conflicts
- Amount Validation: All payments must be positive amounts
- Authorization Checks: Multi-layer authorization verification
- State Consistency: Atomic operations with proper error handling
- Payment Flow Tracking: All payments emit comprehensive events
- Audit Trail: Complete event history for revenue tracking
- Indexer Support: Structured event data for frontend integration
- ✅ Initialization and configuration
- ✅ Access control (vault/admin only)
- ✅ Payment reception to global pool
- ✅ Payment reception to specific developers
- ✅ Input validation (amounts, addresses)
- ✅ Error conditions (unauthorized, invalid inputs)
- ✅ Settlement address configuration
- ✅ Automatic settlement transfers on deduct
- ✅ Batch deduct with settlement transfers
- ✅ Authorization controls for settlement management
- ✅ End-to-end payment flow (vault → settlement → pool)
- ✅ End-to-end developer payment flow
- ✅ Batch operations with settlement integration
- ✅ Multi-transaction scenarios
cd contracts/settlement
cargo test
cd contracts/vault
cargo test
# Run all workspace tests
cargo test --workspace// 1. Initialize settlement contract
let settlement_address = env.deploy_contract("callora-settlement");
CalloraSettlement::init(env, admin_address, vault_address);
// 2. Configure settlement address in vault
CalloraVault::set_settlement(env, admin_address, settlement_address);// Vault deduct (automatically transfers to settlement)
let amount = 1000i128;
CalloraVault::deduct(env, authorized_caller, amount, None);
// Settlement receives payment and credits pool
CalloraSettlement::receive_payment(
env,
vault_address, // authorized caller
amount,
true, // credit to global pool
None, // no specific developer
);// Credit specific developer balance
CalloraSettlement::receive_payment(
env,
vault_address,
amount,
false, // credit to developer, not pool
Some(developer_address), // specify developer
);// Configure USDC if not already configured by admin
CalloraSettlement::set_usdc_token(env, admin_address, usdc_contract_address);
// Developer withdraws their available tracked balance
CalloraSettlement::withdraw_developer_balance(
env,
developer_address,
withdrawal_amount,
);- Batch Processing: Single settlement transfer for batch deducts
- Storage Optimization: Shared storage keys for related data
- Event Batching: Minimal event emissions with comprehensive data
- Single Deduct: ~150,000 gas (including settlement transfer)
- Batch Deduct: ~200,000 gas for 5 items
- Settlement Receive: ~80,000 gas
- Developer Query: ~20,000 gas
- Vault Contract: Must be deployed and initialized
- USDC Token: Must be available on network
- Admin Configuration: Settlement address must be set in vault
# 1. Build contracts
cargo build --release --target wasm32-unknown-unknown
# 2. Deploy settlement contract
soroban contract deploy \
--wasm contracts/settlement/target/wasm32-unknown-unknown/release/callora_settlement.wasm \
--source contracts/settlement/src \
--network testnet
# 3. Configure settlement address in vault
soroban contract invoke \
--id <vault_contract_id> \
--function set_settlement \
--args <admin_address> <settlement_contract_id>To safely rotate the admin address:
# Step 1: Current admin nominates new admin
soroban contract invoke \
--id <settlement_contract_id> \
--function set_admin \
--args <current_admin_address> <new_admin_address>
# Step 2: New admin accepts the role (MUST be called by new admin)
soroban contract invoke \
--id <settlement_contract_id> \
--function accept_admin \
--argsVerification:
# Verify admin has changed
soroban contract invoke \
--id <settlement_contract_id> \
--function get_admin \
--argsExpected Events:
admin_nominated- Emitted when current admin nominates new adminadmin_accepted- Emitted when new admin accepts the role
To update the vault address (e.g., after vault upgrade or migration):
# Admin updates vault address
soroban contract invoke \
--id <settlement_contract_id> \
--function set_vault \
--args <admin_address> <new_vault_address>Verification:
# Verify vault has changed
soroban contract invoke \
--id <settlement_contract_id> \
--function get_vault \
--argsTesting New Vault:
- Send a small test payment from new vault
- Verify payment is credited correctly
- Monitor events for confirmation
- Once verified, resume normal operations
When changing vault address, coordinate with backend systems:
-
Preparation:
- Deploy new vault contract if needed
- Ensure new vault has sufficient USDC balance
- Update backend configuration with new vault address
-
Traffic Management:
- Pause API endpoints that trigger deduct operations
- Wait for pending operations to complete
- Verify no in-flight transactions
-
Contract Update:
- Call
set_vault()on settlement contract - Verify event emission
- Test with small amount
- Call
-
Resume Operations:
- Enable API endpoints
- Monitor first few payments closely
- Watch for any errors or failed transactions
-
Monitoring:
- Track
payment_receivedevents - Verify all payments are credited correctly
- Alert on any authorization failures
- Track
If Admin Key is Compromised:
- Immediately rotate admin using backup key or multi-sig
- Monitor for unauthorized
set_vault()calls - Review recent event logs for suspicious activity
If Vault Address is Incorrect:
- Admin should immediately call
set_vault()with correct address - Verify old vault can no longer send payments
- Check all recent payments were credited correctly
If Payments Fail:
- Check vault address is correct via
get_vault() - Verify caller authorization in transaction logs
- Review event history for error patterns
- Total Volume: Track total USDC processed through settlement
- Pool Balance: Monitor global pool balance over time
- Developer Balances: Individual developer credit tracking
- Payment Frequency: Analyze payment patterns and volumes
// Monitor payment received events
const filter = {
topics: ["payment_received"],
contract: settlement_address
};
// Monitor balance credited events
const devFilter = {
topics: ["balance_credited"],
contract: settlement_address
};- Backward Compatible: All existing vault functions preserved
- Settlement Integration: Non-breaking addition to existing flow
- Configuration: Optional settlement address (can be enabled/disabled)
- Payment Scheduling: Delayed settlement transfers
- Multi-Token Support: Support for multiple payment tokens
- Revenue Splitting: Automatic percentage-based distribution
- Cross-Chain Settlement: Multi-network revenue aggregation
- Unauthorized Access: Multi-layer authorization checks
- Reentrancy Protection: State updates before external calls
- Overflow Protection: i128 arithmetic with overflow checks
- Frontend Protection: Structured JSON responses for all operations
The settlement contract implements a secure two-step admin rotation process:
-
Nomination Phase: Current admin nominates a new admin using
set_admin()- New admin is stored in
PENDING_ADMIN_KEY - Current admin retains full privileges
- Emits
admin_nominatedevent
- New admin is stored in
-
Acceptance Phase: Nominated admin must explicitly accept using
accept_admin()- Prevents unauthorized admin transfers
- Ensures new admin has control of private keys
- Emits
admin_acceptedevent
Security Benefits:
- Prevents accidental admin loss
- Requires active acceptance from new admin
- Allows current admin to change nomination before acceptance
- Clear audit trail through events
Vault address updates use a simpler single-step process:
- Only current admin can call
set_vault() - Update takes effect immediately
- Critical for maintaining payment flow integrity
When rotating admin or updating vault:
-
Admin Rotation:
// Step 1: Current admin nominates new admin set_admin(current_admin, new_admin) // Step 2: New admin accepts (must be done by new admin) accept_admin()
-
Vault Update:
// Single step: Admin updates vault address set_vault(admin, new_vault_address)
-
Coordinated Changes (Backend/Traffic):
- Pause or limit API traffic if needed
- Update vault address in backend systems first
- Call
set_vault()on settlement contract - Verify new vault can send payments
- Resume normal operations
- Monitor events for confirmation
Admin Key Safety:
- Store admin private keys securely (HSM or multi-sig recommended)
- Never commit admin keys to version control
- Rotate admin keys periodically using the two-step process
- Monitor
admin_nominatedandadmin_acceptedevents
Vault Update Risks:
- Incorrect vault address breaks payment processing
- Old vault immediately loses access after update
- Always test new vault address with small amounts first
- Coordinate with backend systems to avoid downtime
Preventing Unauthorized Changes:
- Only current admin can call
set_admin()andset_vault() - All functions require Soroban authentication via
require_auth() - Events emitted for all changes enable monitoring
- Two-step admin transfer prevents hijacking
State Consistency:
- Admin rotation doesn't affect pool balances or developer balances
- Vault updates don't disrupt existing state
- All state preserved across configuration changes
- Regression tests verify consistency
- ✅ Access control implemented correctly
- ✅ Input validation comprehensive
- ✅ Event emission for audit trail
- ✅ Error handling for edge cases
- ✅ Gas optimization implemented
- ✅ Test coverage >95%
- ✅ Documentation complete
- ✅ Admin rotation tested extensively
- ✅ Vault update tested with authorization matrix
- ✅ Regression tests pass for all scenarios
The revenue settlement implementation provides a secure, efficient, and well-tested system for automatically transferring USDC from the vault contract to a settlement contract. The settlement contract then properly credits either a global pool or specific developer balances based on payment parameters.
The implementation maintains backward compatibility while adding powerful new revenue management capabilities to the Callora ecosystem.
As of the persistent storage migration, developer balances have been migrated from a single instance storage Map to per-address persistent storage with automatic TTL extension. This change improves scalability and reduces instance storage pressure as the number of developers grows.
Before Migration:
- Single instance storage key:
developer_balances(Symbol) - Value:
Map<Address, i128>containing all developer balances - Issues:
- Every
receive_paymentto a developer required reading/writing the entire map - Map iteration in
get_all_developer_balancesbecame expensive with many developers - Instance storage size grew linearly with developer count
- Higher archival risk due to large instance storage
- Every
After Migration:
- Storage key enum with variants for different storage types
- Per-developer persistent storage:
StorageKey::DeveloperBalance(Address) - Developer index:
StorageKey::DeveloperIndexcontainingVec<Address>of all developers - Benefits:
- O(1) point read/write for individual developer balances
- Persistent storage with automatic TTL extension (1 year)
- Reduced instance storage pressure (only index stored in instance)
get_all_developer_balancesiterates index instead of map
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum StorageKey {
Admin,
Vault,
PendingAdmin,
DeveloperIndex,
DeveloperBalance(Address),
GlobalPool,
}Before:
let mut balances: Map<Address, i128> = inst
.get(&Symbol::new(&env, DEVELOPER_BALANCES_KEY))
.unwrap_or_else(|| Map::new(&env));
let current_balance = balances.get(dev_address.clone()).unwrap_or(0);
let new_balance = current_balance.checked_add(amount).unwrap_or_else(...);
balances.set(dev_address.clone(), new_balance);
inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &balances);After:
let current_balance = env
.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(dev_address.clone()))
.unwrap_or(0);
let new_balance = current_balance.checked_add(amount).unwrap_or_else(...);
env.storage()
.persistent()
.set(&StorageKey::DeveloperBalance(dev_address.clone()), &new_balance);
env.storage()
.persistent()
.extend_ttl(&StorageKey::DeveloperBalance(dev_address.clone()), 50000, 50000);
// Add to index if not present
let mut index: Vec<Address> = inst
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));
if !index.iter().any(|addr| addr == &dev_address) {
index.push_back(dev_address.clone());
inst.set(&StorageKey::DeveloperIndex, &index);
}Before:
let balances: Map<Address, i128> = inst
.get(&Symbol::new(&env, DEVELOPER_BALANCES_KEY))
.unwrap_or_else(|| Map::new(&env));
balances.get(developer).unwrap_or(0)After:
env.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(developer))
.unwrap_or(0)Before:
let balances: Map<Address, i128> = inst
.get(&Symbol::new(&env, DEVELOPER_BALANCES_KEY))
.unwrap_or_else(|| Map::new(&env));
let mut result = Vec::new(&env);
for (address, balance) in balances.iter() {
result.push_back(DeveloperBalance { address, balance });
}
resultAfter:
let index: Vec<Address> = inst
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));
if index.len() > 100 {
return Err(SettlementError::GasExhaustionRisk);
}
let mut result = Vec::new(&env);
for address in index.iter() {
let balance = env
.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(address))
.unwrap_or(0);
result.push_back(DeveloperBalance {
address: address.clone(),
balance,
});
}
Ok(result)pub fn get_developer_balances_page(
env: Env,
caller: Address,
start: u32,
limit: u32,
) -> Result<Vec<DeveloperBalance>, SettlementError> {
let inst = env.storage().instance();
let index: Vec<Address> = inst
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));
let end = start
.saturating_add(limit.min(50))
.min(index.len());
let mut result = Vec::new(&env);
let mut cursor = 0;
for address in index.iter() {
if cursor >= start && cursor < end {
let balance = env
.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(address.clone()))
.unwrap_or(0);
result.push_back(DeveloperBalance {
address: address.clone(),
balance,
});
}
if cursor >= end {
break;
}
cursor += 1;
}
Ok(result)
}Before:
let empty_balances: Map<Address, i128> = Map::new(&env);
inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &empty_balances);After:
let empty_index: Vec<Address> = Vec::new(&env);
inst.set(&StorageKey::DeveloperIndex, &empty_index);- Contract Upgrade Required: This is a storage-level migration that requires a contract upgrade
- Data Migration: Existing developer balances in the old
Map<Address, i128>format need to be migrated to the new persistent storage format - API Compatibility:
receive_paymentandget_developer_balanceremain unchanged;get_all_developer_balancesnow returns an explicitResultand rejects full iteration once the developer index exceeds 100 entries - New Safe Query:
get_developer_balances_page(start, limit)is added for paginated admin reads and is capped at 50 records per call
- Developer Credit: O(1) point read/write instead of O(n) map operations
- Balance Query: O(1) persistent storage lookup instead of map lookup
- Instance Storage: Reduced pressure as individual balances are in persistent storage
- Scalability: Can handle 100+ developers without significant gas cost increases
- Developer balances now have persistent storage with 1-year TTL
- TTL is automatically extended on every credit via
extend_ttl - Index remains in instance storage (no TTL)
- ✅ Per-address persistent storage read/write
- ✅ TTL extension on credit
- ✅ Developer index management
- ✅
get_developer_balanceO(1) lookup - ✅
get_all_developer_balancesindex iteration - ✅ 100+ developer scalability test
cd contracts/settlement
cargo test
# Test with 100+ developers
cargo test test_scale_many_developersIf issues arise with the new storage layout:
- Contract can be upgraded back to the previous version
- Data migration script can convert persistent storage back to instance storage map
- Monitor gas costs and storage pressure during rollout