diff --git a/contracts/allocation_logic/src/lib.rs b/contracts/allocation_logic/src/lib.rs index 32378b4..faed4c9 100644 --- a/contracts/allocation_logic/src/lib.rs +++ b/contracts/allocation_logic/src/lib.rs @@ -677,6 +677,44 @@ impl AllocationStrategiesContract { }) } + /// Deallocate a commitment after the core contract settles or exits it. + /// + /// Only the configured CommitmentCore address may trigger this lifecycle + /// cleanup. Missing allocations are treated as a no-op so commitments that + /// were never allocated can still settle or early-exit normally. + pub fn deallocate(env: Env, caller: Address, commitment_id: String) -> Result<(), Error> { + caller.require_auth(); + Self::require_initialized(&env)?; + Self::require_no_reentrancy(&env)?; + + let commitment_core: Address = env + .storage() + .instance() + .get(&DataKey::CommitmentCore) + .ok_or(Error::NotInitialized)?; + + if caller != commitment_core { + return Err(Error::Unauthorized); + } + + Pausable::require_not_paused(&env); + + let allocations: Vec = env + .storage() + .persistent() + .get(&DataKey::Allocations(commitment_id.clone())) + .unwrap_or(Vec::new(&env)); + + if allocations.is_empty() { + return Ok(()); + } + + Self::set_reentrancy_guard(&env, true); + let result = Self::deallocate_locked(&env, &commitment_id, &allocations); + Self::set_reentrancy_guard(&env, false); + result + } + // ======================================================================== // VIEW FUNCTIONS // ======================================================================== @@ -970,6 +1008,51 @@ impl AllocationStrategiesContract { .ok_or(Error::PoolNotFound) } + fn deallocate_locked( + env: &Env, + commitment_id: &String, + allocations: &Vec, + ) -> Result<(), Error> { + let mut total_deallocated = 0i128; + + for allocation in allocations.iter() { + total_deallocated = total_deallocated + .checked_add(allocation.amount) + .ok_or(Error::ArithmeticOverflow)?; + + let mut pool = Self::get_pool_internal(env, allocation.pool_id)?; + if pool.total_liquidity < allocation.amount { + return Err(Error::ArithmeticOverflow); + } + pool.total_liquidity = pool + .total_liquidity + .checked_sub(allocation.amount) + .ok_or(Error::ArithmeticOverflow)?; + pool.updated_at = env.ledger().timestamp(); + env.storage() + .persistent() + .set(&DataKey::Pool(allocation.pool_id), &pool); + } + + env.storage() + .persistent() + .remove(&DataKey::Allocations(commitment_id.clone())); + env.storage() + .persistent() + .remove(&DataKey::Strategy(commitment_id.clone())); + env.storage() + .persistent() + .remove(&DataKey::TotalAllocated(commitment_id.clone())); + env.storage() + .persistent() + .remove(&DataKey::AllocationOwner(commitment_id.clone())); + + env.events() + .publish((symbol_short!("dealloc"), commitment_id.clone()), total_deallocated); + + Ok(()) + } + fn select_pools(env: &Env, strategy: Strategy) -> Result, Error> { let mut pools = Vec::new(env); diff --git a/contracts/allocation_logic/src/tests.rs b/contracts/allocation_logic/src/tests.rs index 515b211..fdf676b 100644 --- a/contracts/allocation_logic/src/tests.rs +++ b/contracts/allocation_logic/src/tests.rs @@ -2,8 +2,8 @@ extern crate std; use crate::{ - AllocationStrategiesContract, AllocationStrategiesContractClient, Error, RiskLevel, Strategy, - Commitment, CommitmentRules, CURRENT_VERSION, + AllocationStrategiesContract, AllocationStrategiesContractClient, Commitment, CommitmentRules, + DataKey, Error, Pool, RiskLevel, Strategy, CURRENT_VERSION, }; use soroban_sdk::{ contract, contractimpl, testutils::Address as _, testutils::Ledger, Address, Env, Map, String, @@ -470,6 +470,113 @@ fn test_rebalance_edge_case_zero_allocation() { assert!(result.is_err()); } +#[test] +fn test_deallocate_only_commitment_core_can_call() { + let env = Env::default(); + env.mock_all_auths(); + + let (admin, core_id, client) = create_contract(&env); + setup_test_pools(&env, &client, &admin); + + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let commitment_id = String::from_str(&env, "dealloc_auth"); + let amount = 100_000_000i128; + create_mock_commitment(&env, &core_id, "dealloc_auth", amount, "active"); + client.allocate(&owner, &commitment_id, &amount, &Strategy::Balanced); + + let result = client.try_deallocate(&attacker, &commitment_id); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + + client.deallocate(&core_id, &commitment_id); + assert_eq!(client.get_allocation(&commitment_id).total_allocated, 0); +} + +#[test] +fn test_deallocate_without_allocation_is_noop() { + let env = Env::default(); + env.mock_all_auths(); + + let (_admin, core_id, client) = create_contract(&env); + let commitment_id = String::from_str(&env, "never_allocated"); + + client.deallocate(&core_id, &commitment_id); + + let summary = client.get_allocation(&commitment_id); + assert_eq!(summary.total_allocated, 0); + assert_eq!(summary.allocations.len(), 0); +} + +#[test] +fn test_deallocate_clears_multi_pool_allocation_and_liquidity() { + let env = Env::default(); + env.mock_all_auths(); + + let (admin, core_id, client) = create_contract(&env); + setup_test_pools(&env, &client, &admin); + + let owner = Address::generate(&env); + let commitment_id = String::from_str(&env, "dealloc_multi"); + let amount = 120_000_000i128; + create_mock_commitment(&env, &core_id, "dealloc_multi", amount, "active"); + + let summary = client.allocate(&owner, &commitment_id, &amount, &Strategy::Balanced); + assert!(summary.allocations.len() > 1); + + for allocation in summary.allocations.iter() { + let pool = client.get_pool(&allocation.pool_id); + assert!(pool.total_liquidity >= allocation.amount); + } + + client.deallocate(&core_id, &commitment_id); + + let cleared = client.get_allocation(&commitment_id); + assert_eq!(cleared.total_allocated, 0); + assert_eq!(cleared.allocations.len(), 0); + + for allocation in summary.allocations.iter() { + let pool = client.get_pool(&allocation.pool_id); + assert_eq!(pool.total_liquidity, 0); + } +} + +#[test] +fn test_deallocate_underflow_rejected_and_guard_cleared() { + let env = Env::default(); + env.mock_all_auths(); + + let (admin, core_id, client) = create_contract(&env); + setup_test_pools(&env, &client, &admin); + + let owner = Address::generate(&env); + let commitment_id = String::from_str(&env, "dealloc_underflow"); + let amount = 100_000_000i128; + create_mock_commitment(&env, &core_id, "dealloc_underflow", amount, "active"); + + let summary = client.allocate(&owner, &commitment_id, &amount, &Strategy::Safe); + env.as_contract(&client.address, || { + for allocation in summary.allocations.iter() { + let mut pool: Pool = env + .storage() + .persistent() + .get(&DataKey::Pool(allocation.pool_id)) + .unwrap(); + pool.total_liquidity = 0; + env.storage() + .persistent() + .set(&DataKey::Pool(allocation.pool_id), &pool); + } + }); + + let first = summary.allocations.get(0).unwrap(); + assert_eq!(client.get_pool(&first.pool_id).total_liquidity, 0); + + let result = client.try_deallocate(&core_id, &commitment_id); + assert_eq!(result, Err(Ok(Error::ArithmeticOverflow))); + + client.deallocate(&core_id, &String::from_str(&env, "missing_after_error")); +} + #[test] fn test_rebalance_with_capacity_constraints() { let env = Env::default(); diff --git a/contracts/commitment_core/src/lib.rs b/contracts/commitment_core/src/lib.rs index 0269de6..71a6527 100644 --- a/contracts/commitment_core/src/lib.rs +++ b/contracts/commitment_core/src/lib.rs @@ -220,6 +220,19 @@ fn transfer_assets(e: &Env, from: &Address, to: &Address, asset_address: &Addres token_client.transfer(from, to, &amount); } +fn deallocate_if_configured(e: &Env, commitment_id: &String) { + if let Some(allocation_contract) = e + .storage() + .instance() + .get::<_, Address>(&DataKey::AllocationContract) + { + let mut args = Vec::new(e); + args.push_back(e.current_contract_address().into_val(e)); + args.push_back(commitment_id.clone().into_val(e)); + e.invoke_contract::<()>(&allocation_contract, &Symbol::new(e, "deallocate"), args); + } +} + /// Helper function to call NFT contract mint function. fn call_nft_mint( e: &Env, @@ -1072,6 +1085,8 @@ impl CommitmentCoreContract { }; e.storage().instance().set(&DataKey::TotalValueLocked, &new_tvl); + deallocate_if_configured(&e, &commitment_id); + transfer_assets( &e, &e.current_contract_address(), @@ -1192,6 +1207,8 @@ impl CommitmentCoreContract { .instance() .set(&DataKey::TotalValueLocked, &(tvl - original_val)); + deallocate_if_configured(&e, &commitment_id); + if returned > 0 { transfer_assets( &e, diff --git a/contracts/commitment_core/src/tests.rs b/contracts/commitment_core/src/tests.rs index 54e849b..f782006 100644 --- a/contracts/commitment_core/src/tests.rs +++ b/contracts/commitment_core/src/tests.rs @@ -33,6 +33,28 @@ impl MockNftContract { pub fn mark_inactive(_e: Env, _caller: Address, _token_id: u32) {} } +#[contract] +struct MockAllocationContract; + +#[contractimpl] +impl MockAllocationContract { + pub fn deallocate(e: Env, caller: Address, commitment_id: String) { + caller.require_auth(); + e.storage() + .instance() + .set(&symbol_short!("dealloc"), &commitment_id); + e.storage().instance().set(&symbol_short!("caller"), &caller); + } + + pub fn last_deallocated(e: Env) -> Option { + e.storage().instance().get(&symbol_short!("dealloc")) + } + + pub fn last_caller(e: Env) -> Option
{ + e.storage().instance().get(&symbol_short!("caller")) + } +} + mod instrumented_nft { use super::*; @@ -1974,6 +1996,102 @@ fn test_settle_success_expired() { assert_eq!(owner_commitments.len(), 0); } +#[test] +fn test_settle_invokes_configured_allocation_deallocate() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let contract_id = e.register_contract(None, CommitmentCoreContract); + let nft_contract = e.register_contract(None, MockNftContract); + let allocation_contract = e.register_contract(None, MockAllocationContract); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let token_admin = Address::generate(&e); + let commitment_id = "settle_dealloc"; + + let token_contract = e.register_stellar_asset_contract_v2(token_admin); + let asset_address = token_contract.address(); + let amount = 1000i128; + StellarAssetClient::new(&e, &asset_address).mint(&contract_id, &amount); + + let client = CommitmentCoreContractClient::new(&e, &contract_id); + client.initialize(&admin, &nft_contract); + client.set_allocation_contract(&admin, &allocation_contract); + + let created_at = 1000u64; + let duration_days = 30u32; + let expires_at = created_at + (duration_days as u64) * 86400; + let mut commitment = create_test_commitment( + &e, + commitment_id, + &owner, + amount, + amount, + 10, + duration_days, + created_at, + ); + commitment.asset_address = asset_address; + store_commitment(&e, &contract_id, &commitment); + + e.as_contract(&contract_id, || { + e.storage().instance().set(&DataKey::TotalValueLocked, &amount); + }); + e.ledger().with_mut(|l| { + l.timestamp = expires_at; + }); + + client.settle(&String::from_str(&e, commitment_id)); + + let allocation_client = MockAllocationContractClient::new(&e, &allocation_contract); + assert_eq!( + allocation_client.last_deallocated().unwrap(), + String::from_str(&e, commitment_id) + ); + assert_eq!(allocation_client.last_caller().unwrap(), contract_id); +} + +#[test] +fn test_early_exit_invokes_configured_allocation_deallocate() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let contract_id = e.register_contract(None, CommitmentCoreContract); + let nft_contract = e.register_contract(None, MockNftContract); + let allocation_contract = e.register_contract(None, MockAllocationContract); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let token_admin = Address::generate(&e); + let commitment_id = "exit_dealloc"; + + let token_contract = e.register_stellar_asset_contract_v2(token_admin); + let asset_address = token_contract.address(); + let amount = 1000i128; + StellarAssetClient::new(&e, &asset_address).mint(&contract_id, &amount); + + let client = CommitmentCoreContractClient::new(&e, &contract_id); + client.initialize(&admin, &nft_contract); + client.set_allocation_contract(&admin, &allocation_contract); + + let mut commitment = + create_test_commitment(&e, commitment_id, &owner, amount, amount, 10, 30, 1000); + commitment.asset_address = asset_address; + store_commitment(&e, &contract_id, &commitment); + + e.as_contract(&contract_id, || { + e.storage().instance().set(&DataKey::TotalValueLocked, &amount); + }); + + client.early_exit(&String::from_str(&e, commitment_id), &owner); + + let allocation_client = MockAllocationContractClient::new(&e, &allocation_contract); + assert_eq!( + allocation_client.last_deallocated().unwrap(), + String::from_str(&e, commitment_id) + ); + assert_eq!(allocation_client.last_caller().unwrap(), contract_id); +} + #[test] /// settle must coordinate with the NFT contract (Issue #115). fn test_settle_nft_coordination() { diff --git a/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md b/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md index 7156dc0..3646c78 100644 --- a/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md +++ b/docs/ALLOCATION_LOGIC_POOL_REGISTRY_AND_RISK_LEVELS.md @@ -12,6 +12,10 @@ 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`. +- Settlement cleanup is **CommitmentCore-authorized**: + - `deallocate(caller, commitment_id)` requires `caller.require_auth()`. + - `caller` must match the configured `CommitmentCore` address stored at initialization. + - User wallets, admins, and arbitrary contracts cannot deallocate a commitment directly. The contract does not validate commitment ownership against `commitment_core`; allocations are tracked locally within `allocation_logic` (see `docs/ARCHITECTURE.md`). @@ -89,6 +93,22 @@ Aggressive strategy risk split: Within each bucket, the bucket amount is distributed across pools with the same deterministic remainder behavior. +## Settlement and Early-Exit Deallocation + +When `commitment_core` has an `AllocationContract` configured, successful `settle(commitment_id)` and `early_exit(commitment_id, caller)` calls invoke: + +`allocation_logic::deallocate(core_contract_address, commitment_id)` + +Deallocation is idempotent for commitments that were never allocated. This preserves the existing no-allocation settle and early-exit flows while allowing allocated commitments to release pool liquidity during the same lifecycle transaction. + +For allocated commitments, `deallocate`: + +- Loads the stored allocations for `commitment_id`. +- Subtracts each allocation amount from the corresponding pool's `total_liquidity`. +- Rejects any subtraction that would make pool liquidity negative. +- Removes `Allocations(commitment_id)`, `Strategy(commitment_id)`, `TotalAllocated(commitment_id)`, and `AllocationOwner(commitment_id)`. +- Emits a `dealloc` event with the total released amount. + ### Capacity Enforcement and Failure Mode Each pool has an enforced capacity: