diff --git a/contracts/allocation_logic/src/benchmarks.rs b/contracts/allocation_logic/src/benchmarks.rs index 3ade348..3f2b39d 100644 --- a/contracts/allocation_logic/src/benchmarks.rs +++ b/contracts/allocation_logic/src/benchmarks.rs @@ -2,6 +2,7 @@ #![cfg(feature = "benchmark")] use super::*; +use shared_utils::BatchMode; use soroban_sdk::{testutils::Address as _, Address, Env, String}; /// Benchmark helper to measure gas usage @@ -119,7 +120,7 @@ fn benchmark_allocate() { let _ = AllocationStrategiesContract::allocate( e.clone(), caller.clone(), - 1, + String::from_str(&e, "bench_allocate"), 1000_0000000, Strategy::Safe, ); @@ -147,21 +148,20 @@ fn benchmark_get_allocation() { 10000_0000000, ) .unwrap(); - AllocationStrategiesContract::allocate( + let _ = AllocationStrategiesContract::allocate( e.clone(), caller.clone(), - 1, + String::from_str(&e, "bench_get_allocation"), 1000_0000000, Strategy::Safe, - ) - .unwrap(); + ); }); let mut metrics = BenchmarkMetrics::new("get_allocation"); e.as_contract(&contract_id, || { let start = e.ledger().sequence(); - AllocationStrategiesContract::get_allocation(e.clone(), 1); + AllocationStrategiesContract::get_allocation(e.clone(), String::from_str(&e, "bench_get_allocation")); let end = e.ledger().sequence(); metrics.record_gas(start, end); }); @@ -221,19 +221,26 @@ fn benchmark_batch_allocate() { let mut metrics = BenchmarkMetrics::new("batch_allocate_10"); - let start = e.ledger().sequence(); - for i in 1..=10 { - let caller = Address::generate(&e); - e.as_contract(&contract_id, || { - let _ = AllocationStrategiesContract::allocate( - e.clone(), - caller.clone(), - i, - 1000_0000000, - Strategy::Safe, - ); + let caller = Address::generate(&e); + let mut params = Vec::new(&e); + for _ in 1..=10 { + params.push_back(BatchAllocateParams { + caller: caller.clone(), + commitment_id: String::from_str(&e, "bench_commit"), + amount: 1000_0000000, + strategy: Strategy::Safe, }); } + + let start = e.ledger().sequence(); + e.as_contract(&contract_id, || { + let _ = AllocationStrategiesContract::batch_allocate( + e.clone(), + admin.clone(), + params, + BatchMode::BestEffort, + ); + }); let end = e.ledger().sequence(); metrics.record_gas(start, end); diff --git a/contracts/allocation_logic/src/lib.rs b/contracts/allocation_logic/src/lib.rs index 32378b4..23c9567 100644 --- a/contracts/allocation_logic/src/lib.rs +++ b/contracts/allocation_logic/src/lib.rs @@ -1,7 +1,7 @@ // Allocation Strategies Contract #![no_std] -use shared_utils::{Pausable, RateLimiter}; +use shared_utils::{BatchError, BatchMode, BatchProcessor, Pausable, RateLimiter}; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, IntoVal, Map, String, Symbol, TryIntoVal, Val, Vec, @@ -103,6 +103,23 @@ pub struct AllocationSummary { pub allocations: Vec, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchAllocateParams { + pub caller: Address, + pub commitment_id: String, + pub amount: i128, + pub strategy: Strategy, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchAllocateResult { + pub success: bool, + pub results: Vec, + pub errors: Vec, +} + // Import Commitment types from commitment_core (re-defined here for cross-contract calls) #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -378,148 +395,127 @@ impl AllocationStrategiesContract { // at the protocol level, ensuring the address matches the transaction signer caller.require_auth(); - Self::require_initialized(&env)?; - Self::require_no_reentrancy(&env)?; - - // Rate limit allocations per caller address - let fn_symbol = symbol_short!("alloc"); - RateLimiter::check(&env, &caller, &fn_symbol); - - // Set reentrancy guard - Self::set_reentrancy_guard(&env, true); - - // Check if contract is paused - Pausable::require_not_paused(&env); - - // Input validation - if amount <= 0 { - Self::set_reentrancy_guard(&env, false); - return Err(Error::InvalidAmount); - } + Self::allocate_internal(env, caller, commitment_id, amount, strategy) + } - // Check commitment balance and status - let commitment_balance = Self::get_commitment_balance(&env, commitment_id.clone())?; - if amount > commitment_balance { - Self::set_reentrancy_guard(&env, false); - return Err(Error::InsufficientCommitmentBalance); - } + /// Allocate multiple commitments in one call using shared batch limits. + /// + /// `BatchMode::Atomic` pre-validates every item against a simulated pool + /// liquidity view before writing; if any item fails, no allocations are + /// applied. `BatchMode::BestEffort` processes each item independently and + /// returns per-index errors while preserving successful allocations. + pub fn batch_allocate( + env: Env, + admin: Address, + params: Vec, + mode: BatchMode, + ) -> BatchAllocateResult { + admin.require_auth(); - // Check for existing allocation (prevent double allocation) - if env - .storage() - .persistent() - .has(&DataKey::Allocations(commitment_id.clone())) + let batch_size = params.len(); + let contract_name = String::from_str(&env, "allocation_logic"); + if let Err(error_code) = + BatchProcessor::enforce_batch_limits(&env, batch_size, Some(contract_name)) { - Self::set_reentrancy_guard(&env, false); - return Err(Error::AlreadyInitialized); + let mut errors = Vec::new(&env); + errors.push_back(BatchError { + index: 0, + error_code, + context: String::from_str(&env, "batch_size_validation"), + }); + return BatchAllocateResult { + success: false, + results: Vec::new(&env), + errors, + }; } - // Store allocation ownership - env.storage() - .persistent() - .set(&DataKey::AllocationOwner(commitment_id.clone()), &caller); - - // Store the strategy - env.storage() - .persistent() - .set(&DataKey::Strategy(commitment_id.clone()), &strategy); - - // Get pools based on strategy - let pools = Self::select_pools(&env, strategy)?; - - if pools.is_empty() { - Self::set_reentrancy_guard(&env, false); - return Err(Error::NoSuitablePools); + if let Err(error) = Self::require_initialized(&env) { + let mut errors = Vec::new(&env); + errors.push_back(Self::batch_error( + &env, + 0, + error, + String::from_str(&env, "not_initialized"), + )); + return BatchAllocateResult { + success: false, + results: Vec::new(&env), + errors, + }; } - // Calculate allocation amounts with overflow protection - let allocation_plan = Self::calculate_allocation(&env, amount, &pools, strategy)?; - - // Execute allocations - let mut allocations = Vec::new(&env); - let mut total_allocated = 0i128; - - for (pool_id, alloc_amount) in allocation_plan.iter() { - if alloc_amount <= 0 { - continue; - } - - // Update pool liquidity with overflow check - let mut pool = Self::get_pool_internal(&env, pool_id)?; - - // Check pool is active - if !pool.active { - Self::set_reentrancy_guard(&env, false); - return Err(Error::PoolInactive); - } - - // Safe addition with overflow check - let new_liquidity = pool - .total_liquidity - .checked_add(alloc_amount) - .ok_or(Error::ArithmeticOverflow)?; - - if new_liquidity > pool.max_capacity { - Self::set_reentrancy_guard(&env, false); - return Err(Error::PoolCapacityExceeded); - } - - pool.total_liquidity = new_liquidity; - pool.updated_at = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&DataKey::Pool(pool_id), &pool); - - // Record allocation - let allocation = Allocation { - commitment_id: commitment_id.clone(), - pool_id, - amount: alloc_amount, - timestamp: env.ledger().timestamp(), + if let Err(error) = Self::require_admin(&env, &admin) { + let mut errors = Vec::new(&env); + errors.push_back(Self::batch_error( + &env, + 0, + error, + String::from_str(&env, "not_admin"), + )); + return BatchAllocateResult { + success: false, + results: Vec::new(&env), + errors, }; + } - allocations.push_back(allocation); + Self::require_batch_callers_auth(&env, &admin, ¶ms); - // Safe addition - total_allocated = total_allocated - .checked_add(alloc_amount) - .ok_or(Error::ArithmeticOverflow)?; + if mode == BatchMode::Atomic { + if let Err(error) = Self::validate_batch_allocations(&env, ¶ms) { + return BatchAllocateResult { + success: false, + results: Vec::new(&env), + errors: error, + }; + } } - // Verify total matches requested amount - if total_allocated != amount { - Self::set_reentrancy_guard(&env, false); - // Under-allocation should be treated as a capacity failure; over-allocation - // indicates a logic/arithmetic bug. - if total_allocated < amount { - return Err(Error::PoolCapacityExceeded); + let mut results = Vec::new(&env); + let mut errors = Vec::new(&env); + + for i in 0..batch_size { + let param = params.get(i).unwrap(); + match Self::allocate_internal( + env.clone(), + param.caller.clone(), + param.commitment_id.clone(), + param.amount, + param.strategy, + ) { + Ok(summary) => results.push_back(summary), + Err(error) => { + let batch_error = Self::batch_error( + &env, + i, + error, + param.commitment_id.clone(), + ); + if mode == BatchMode::Atomic { + let mut atomic_errors = Vec::new(&env); + atomic_errors.push_back(batch_error); + return BatchAllocateResult { + success: false, + results: Vec::new(&env), + errors: atomic_errors, + }; + } + errors.push_back(batch_error); + } } - return Err(Error::ArithmeticOverflow); } - // Store allocations - env.storage() - .persistent() - .set(&DataKey::Allocations(commitment_id.clone()), &allocations); - env.storage() - .persistent() - .set(&DataKey::TotalAllocated(commitment_id.clone()), &total_allocated); - - // Clear reentrancy guard - Self::set_reentrancy_guard(&env, false); - - // Emit event env.events().publish( - (symbol_short!("allocate"), commitment_id.clone()), - (strategy, amount), + (Symbol::new(&env, "BatchAllocate"), batch_size), + (results.len(), errors.len(), env.ledger().timestamp()), ); - Ok(AllocationSummary { - commitment_id, - strategy, - total_allocated, - allocations, - }) + BatchAllocateResult { + success: errors.is_empty(), + results, + errors, + } } /// Rebalances an existing allocation using the stored strategy. @@ -839,6 +835,258 @@ impl AllocationStrategiesContract { Ok(commitment.current_value) } + fn allocate_internal( + env: Env, + caller: Address, + commitment_id: String, + amount: i128, + strategy: Strategy, + ) -> Result { + Self::require_initialized(&env)?; + Self::require_no_reentrancy(&env)?; + + let fn_symbol = symbol_short!("alloc"); + RateLimiter::check(&env, &caller, &fn_symbol); + + Self::set_reentrancy_guard(&env, true); + Pausable::require_not_paused(&env); + + if amount <= 0 { + Self::set_reentrancy_guard(&env, false); + return Err(Error::InvalidAmount); + } + + let commitment_balance = Self::get_commitment_balance(&env, commitment_id.clone())?; + if amount > commitment_balance { + Self::set_reentrancy_guard(&env, false); + return Err(Error::InsufficientCommitmentBalance); + } + + if env + .storage() + .persistent() + .has(&DataKey::Allocations(commitment_id.clone())) + { + Self::set_reentrancy_guard(&env, false); + return Err(Error::AlreadyInitialized); + } + + env.storage() + .persistent() + .set(&DataKey::AllocationOwner(commitment_id.clone()), &caller); + env.storage() + .persistent() + .set(&DataKey::Strategy(commitment_id.clone()), &strategy); + + let pools = Self::select_pools(&env, strategy)?; + if pools.is_empty() { + Self::set_reentrancy_guard(&env, false); + return Err(Error::NoSuitablePools); + } + + let allocation_plan = Self::calculate_allocation(&env, amount, &pools, strategy)?; + let mut allocations = Vec::new(&env); + let mut total_allocated = 0i128; + + for (pool_id, alloc_amount) in allocation_plan.iter() { + if alloc_amount <= 0 { + continue; + } + + let mut pool = Self::get_pool_internal(&env, pool_id)?; + if !pool.active { + Self::set_reentrancy_guard(&env, false); + return Err(Error::PoolInactive); + } + + let new_liquidity = pool + .total_liquidity + .checked_add(alloc_amount) + .ok_or(Error::ArithmeticOverflow)?; + if new_liquidity > pool.max_capacity { + Self::set_reentrancy_guard(&env, false); + return Err(Error::PoolCapacityExceeded); + } + + pool.total_liquidity = new_liquidity; + pool.updated_at = env.ledger().timestamp(); + env.storage() + .persistent() + .set(&DataKey::Pool(pool_id), &pool); + + let allocation = Allocation { + commitment_id: commitment_id.clone(), + pool_id, + amount: alloc_amount, + timestamp: env.ledger().timestamp(), + }; + allocations.push_back(allocation); + total_allocated = total_allocated + .checked_add(alloc_amount) + .ok_or(Error::ArithmeticOverflow)?; + } + + if total_allocated != amount { + Self::set_reentrancy_guard(&env, false); + if total_allocated < amount { + return Err(Error::PoolCapacityExceeded); + } + return Err(Error::ArithmeticOverflow); + } + + env.storage() + .persistent() + .set(&DataKey::Allocations(commitment_id.clone()), &allocations); + env.storage() + .persistent() + .set(&DataKey::TotalAllocated(commitment_id.clone()), &total_allocated); + + Self::set_reentrancy_guard(&env, false); + + env.events().publish( + (symbol_short!("allocate"), commitment_id.clone()), + (strategy, amount), + ); + + Ok(AllocationSummary { + commitment_id, + strategy, + total_allocated, + allocations, + }) + } + + fn require_batch_callers_auth(env: &Env, admin: &Address, params: &Vec) { + let mut authorized = Vec::
::new(env); + authorized.push_back(admin.clone()); + for i in 0..params.len() { + let caller = params.get(i).unwrap().caller; + let mut already_authorized = false; + for existing in authorized.iter() { + if existing == caller { + already_authorized = true; + break; + } + } + if !already_authorized { + caller.require_auth(); + authorized.push_back(caller); + } + } + } + + fn batch_error(_env: &Env, index: u32, error: Error, context: String) -> BatchError { + BatchError { + index, + error_code: error as u32, + context, + } + } + + fn validate_batch_allocations( + env: &Env, + params: &Vec, + ) -> Result<(), Vec> { + let mut errors = Vec::new(env); + let mut simulated_pools = Map::::new(env); + let mut seen_commitments = Vec::::new(env); + + for i in 0..params.len() { + let param = params.get(i).unwrap(); + if let Err(error) = Self::validate_batch_item( + env, + ¶m, + &mut simulated_pools, + &mut seen_commitments, + ) { + errors.push_back(Self::batch_error( + env, + i, + error, + param.commitment_id.clone(), + )); + return Err(errors); + } + } + + Ok(()) + } + + fn validate_batch_item( + env: &Env, + param: &BatchAllocateParams, + simulated_pools: &mut Map, + seen_commitments: &mut Vec, + ) -> Result<(), Error> { + if param.amount <= 0 { + return Err(Error::InvalidAmount); + } + + if env + .storage() + .persistent() + .has(&DataKey::Allocations(param.commitment_id.clone())) + { + return Err(Error::AlreadyInitialized); + } + + for seen in seen_commitments.iter() { + if seen == param.commitment_id { + return Err(Error::AlreadyInitialized); + } + } + seen_commitments.push_back(param.commitment_id.clone()); + + let commitment_balance = Self::get_commitment_balance(env, param.commitment_id.clone())?; + if param.amount > commitment_balance { + return Err(Error::InsufficientCommitmentBalance); + } + + let pools = Self::select_pools(env, param.strategy)?; + if pools.is_empty() { + return Err(Error::NoSuitablePools); + } + + let allocation_plan = Self::calculate_allocation(env, param.amount, &pools, param.strategy)?; + let mut total_allocated = 0i128; + + for (pool_id, alloc_amount) in allocation_plan.iter() { + if alloc_amount <= 0 { + continue; + } + + let mut pool = simulated_pools + .get(pool_id) + .unwrap_or(Self::get_pool_internal(env, pool_id)?); + if !pool.active { + return Err(Error::PoolInactive); + } + + let new_liquidity = pool + .total_liquidity + .checked_add(alloc_amount) + .ok_or(Error::ArithmeticOverflow)?; + if new_liquidity > pool.max_capacity { + return Err(Error::PoolCapacityExceeded); + } + + pool.total_liquidity = new_liquidity; + simulated_pools.set(pool_id, pool); + total_allocated = total_allocated + .checked_add(alloc_amount) + .ok_or(Error::ArithmeticOverflow)?; + } + + if total_allocated != param.amount { + if total_allocated < param.amount { + return Err(Error::PoolCapacityExceeded); + } + return Err(Error::ArithmeticOverflow); + } + + Ok(()) + } + fn require_initialized(env: &Env) -> Result<(), Error> { if !env .storage() diff --git a/contracts/allocation_logic/src/tests.rs b/contracts/allocation_logic/src/tests.rs index 515b211..a35043e 100644 --- a/contracts/allocation_logic/src/tests.rs +++ b/contracts/allocation_logic/src/tests.rs @@ -2,9 +2,10 @@ extern crate std; use crate::{ - AllocationStrategiesContract, AllocationStrategiesContractClient, Error, RiskLevel, Strategy, - Commitment, CommitmentRules, CURRENT_VERSION, + AllocationStrategiesContract, AllocationStrategiesContractClient, BatchAllocateParams, + Commitment, CommitmentRules, Error, RiskLevel, Strategy, CURRENT_VERSION, }; +use shared_utils::BatchMode; use soroban_sdk::{ contract, contractimpl, testutils::Address as _, testutils::Ledger, Address, Env, Map, String, Symbol, Vec, IntoVal, @@ -90,6 +91,191 @@ fn setup_test_pools(_env: &Env, client: &AllocationStrategiesContractClient, adm client.register_pool(admin, &5, &RiskLevel::High, &2500, &500_000_000); } +fn batch_param( + env: &Env, + caller: &Address, + commitment_id: &str, + amount: i128, + strategy: Strategy, +) -> BatchAllocateParams { + BatchAllocateParams { + caller: caller.clone(), + commitment_id: String::from_str(env, commitment_id), + amount, + strategy, + } +} + +#[test] +fn test_batch_allocate_atomic_success() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, core_id, client) = create_contract(&env); + setup_test_pools(&env, &client, &admin); + + let caller = Address::generate(&env); + create_mock_commitment(&env, &core_id, "batch_1", 100_000_000, "active"); + create_mock_commitment(&env, &core_id, "batch_2", 200_000_000, "active"); + + let params = soroban_sdk::vec![ + &env, + batch_param(&env, &caller, "batch_1", 100_000_000, Strategy::Safe), + batch_param(&env, &caller, "batch_2", 200_000_000, Strategy::Balanced), + ]; + + let result = client.batch_allocate(&admin, ¶ms, &BatchMode::Atomic); + assert!(result.success); + assert_eq!(result.results.len(), 2); + assert_eq!(result.errors.len(), 0); + assert_eq!( + client + .get_allocation(&String::from_str(&env, "batch_1")) + .total_allocated, + 100_000_000 + ); + assert_eq!( + client + .get_allocation(&String::from_str(&env, "batch_2")) + .total_allocated, + 200_000_000 + ); +} + +#[test] +fn test_batch_allocate_best_effort_partial_failure() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, core_id, client) = create_contract(&env); + setup_test_pools(&env, &client, &admin); + + let caller = Address::generate(&env); + create_mock_commitment(&env, &core_id, "batch_ok", 100_000_000, "active"); + create_mock_commitment(&env, &core_id, "batch_bad", 100_000_000, "active"); + create_mock_commitment(&env, &core_id, "batch_after", 100_000_000, "active"); + + let params = soroban_sdk::vec![ + &env, + batch_param(&env, &caller, "batch_ok", 100_000_000, Strategy::Safe), + batch_param(&env, &caller, "batch_bad", -1, Strategy::Safe), + batch_param(&env, &caller, "batch_after", 100_000_000, Strategy::Safe), + ]; + + let result = client.batch_allocate(&admin, ¶ms, &BatchMode::BestEffort); + assert!(!result.success); + assert_eq!(result.results.len(), 2); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors.get(0).unwrap().index, 1); + assert_eq!(result.errors.get(0).unwrap().error_code, Error::InvalidAmount as u32); + assert_eq!( + client + .get_allocation(&String::from_str(&env, "batch_ok")) + .total_allocated, + 100_000_000 + ); + assert_eq!( + client + .get_allocation(&String::from_str(&env, "batch_after")) + .total_allocated, + 100_000_000 + ); + assert_eq!( + client + .get_allocation(&String::from_str(&env, "batch_bad")) + .total_allocated, + 0 + ); +} + +#[test] +fn test_batch_allocate_allows_admin_as_item_caller() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, core_id, client) = create_contract(&env); + setup_test_pools(&env, &client, &admin); + + create_mock_commitment(&env, &core_id, "batch_admin", 50_000_000, "active"); + let params = soroban_sdk::vec![ + &env, + batch_param(&env, &admin, "batch_admin", 50_000_000, Strategy::Safe), + ]; + + let result = client.batch_allocate(&admin, ¶ms, &BatchMode::Atomic); + assert!(result.success); + assert_eq!(result.results.len(), 1); + assert_eq!(result.errors.len(), 0); +} + +#[test] +fn test_batch_allocate_atomic_capacity_failure_writes_nothing() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, core_id, client) = create_contract(&env); + client.register_pool(&admin, &0, &RiskLevel::Low, &500, &150_000_000); + + let caller = Address::generate(&env); + create_mock_commitment(&env, &core_id, "batch_cap_1", 100_000_000, "active"); + create_mock_commitment(&env, &core_id, "batch_cap_2", 100_000_000, "active"); + + let params = soroban_sdk::vec![ + &env, + batch_param(&env, &caller, "batch_cap_1", 100_000_000, Strategy::Safe), + batch_param(&env, &caller, "batch_cap_2", 100_000_000, Strategy::Safe), + ]; + + let result = client.batch_allocate(&admin, ¶ms, &BatchMode::Atomic); + assert!(!result.success); + assert_eq!(result.results.len(), 0); + assert_eq!(result.errors.len(), 1); + assert_eq!( + result.errors.get(0).unwrap().error_code, + Error::PoolCapacityExceeded as u32 + ); + assert_eq!( + client + .get_allocation(&String::from_str(&env, "batch_cap_1")) + .total_allocated, + 0 + ); + assert_eq!(client.get_pool(&0).total_liquidity, 0); +} + +#[test] +fn test_batch_allocate_empty_batch_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, _core_id, client) = create_contract(&env); + let params = Vec::::new(&env); + + let result = client.batch_allocate(&admin, ¶ms, &BatchMode::BestEffort); + assert!(!result.success); + assert_eq!(result.results.len(), 0); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors.get(0).unwrap().error_code, 1); +} + +#[test] +fn test_batch_allocate_accepts_default_max_batch_size() { + let env = Env::default(); + env.budget().reset_unlimited(); + env.mock_all_auths(); + let (admin, core_id, client) = create_contract(&env); + client.register_pool(&admin, &0, &RiskLevel::Low, &500, &10_000_000_000); + + let caller = Address::generate(&env); + let mut params = Vec::new(&env); + for i in 0..50 { + let id = std::format!("batch_max_{}", i); + create_mock_commitment(&env, &core_id, &id, 1_000_000, "active"); + params.push_back(batch_param(&env, &caller, &id, 1_000_000, Strategy::Safe)); + } + + let result = client.batch_allocate(&admin, ¶ms, &BatchMode::Atomic); + assert!(result.success); + assert_eq!(result.results.len(), 50); + assert_eq!(result.errors.len(), 0); + assert_eq!(client.get_pool(&0).total_liquidity, 50_000_000); +} + #[test] fn test_migrate_rejects_wrong_from_version_without_mutating_state() { let env = Env::default(); diff --git a/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md b/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md index 7156dc0..8fd48a2 100644 --- a/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md +++ b/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md @@ -12,6 +12,9 @@ This page is an operational guide for integrators interacting with the `allocati - Allocation entry points are **caller-authorized**: - `allocate(caller, ...)` requires `caller.require_auth()`. - `rebalance(caller, commitment_id)` requires `caller.require_auth()` and `caller` must match the stored allocation owner for `commitment_id`. +- Batch allocation is **admin/operator-authorized**: + - `batch_allocate(admin, params, mode)` requires `admin.require_auth()` and `admin` must match the stored `Admin`. + - Each batch item still uses the regular `allocate(caller, ...)` path, so the item caller must also authorize and rate-limit/capacity/reentrancy checks remain unchanged. The contract does not validate commitment ownership against `commitment_core`; allocations are tracked locally within `allocation_logic` (see `docs/ARCHITECTURE.md`). @@ -102,6 +105,22 @@ If the requested amount cannot be fully satisfied across the eligible pools due This is a hard failure (no partial success), and state changes are reverted by the transaction. +## Batch Allocation Semantics + +`batch_allocate(admin, params, mode) -> BatchAllocateResult` + +Batch allocation accepts a bounded vector of `BatchAllocateParams` and uses `shared_utils::BatchProcessor` limits. The default maximum batch size is 50 unless the shared batch configuration is overridden. + +Modes: + +- `BatchMode::Atomic`: pre-validates all items against a simulated pool-liquidity view. If any item would fail, the result contains one `BatchError` and no allocations are written. +- `BatchMode::BestEffort`: processes items independently. Successful items are committed, failing items are reported by index in `errors`, and later items continue. + +Per-item behavior: + +- Each item follows the same allocation logic as `allocate`, including commitment balance/status lookup, pool activity checks, deterministic rounding, capacity enforcement, duplicate-allocation rejection, and reentrancy cleanup. +- Capacity checks account for earlier successful items in the same batch. In atomic mode this happens during preflight; in best-effort mode this happens naturally as successful items update pool liquidity. +- Empty batches and batches over the configured limit return a failed `BatchAllocateResult` with a batch-size validation error. ## Capacity Boundary Test Matrix