Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions contracts/allocation_logic/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Allocation> = 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
// ========================================================================
Expand Down Expand Up @@ -970,6 +1008,51 @@ impl AllocationStrategiesContract {
.ok_or(Error::PoolNotFound)
}

fn deallocate_locked(
env: &Env,
commitment_id: &String,
allocations: &Vec<Allocation>,
) -> 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<Vec<Pool>, Error> {
let mut pools = Vec::new(env);

Expand Down
111 changes: 109 additions & 2 deletions contracts/allocation_logic/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions contracts/commitment_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
118 changes: 118 additions & 0 deletions contracts/commitment_core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
e.storage().instance().get(&symbol_short!("dealloc"))
}

pub fn last_caller(e: Env) -> Option<Address> {
e.storage().instance().get(&symbol_short!("caller"))
}
}

mod instrumented_nft {
use super::*;

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading