Skip to content

Commit 8ddf1e7

Browse files
authored
Merge pull request #1577 from Emmzyemms/FEATURE
[FEATURE] Add timelock delay / Add close_portfolio/ Add global slippa…
2 parents 04d0e33 + 6da86ca commit 8ddf1e7

3 files changed

Lines changed: 655 additions & 7 deletions

File tree

contracts/src/lib.rs

Lines changed: 227 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ impl PortfolioRebalancer {
137137
total_value: 0,
138138
is_active: true,
139139
pause_reason: PauseReason::None,
140+
circuit_breaker_config: CircuitBreakerConfig {
141+
spike_threshold_bps: DEFAULT_CIRCUIT_BREAKER_SPIKE_THRESHOLD_BPS,
142+
window_seconds: DEFAULT_CIRCUIT_BREAKER_WINDOW_SECONDS,
143+
},
144+
global_max_slippage_bps: DEFAULT_GLOBAL_MAX_SLIPPAGE_BPS,
140145
};
141146

142147
let _estimated_footprint =
@@ -472,9 +477,46 @@ impl PortfolioRebalancer {
472477
if config.fee_bps > MAX_FEE_BPS {
473478
panic!("fee_bps must be between 0 and 50");
474479
}
475-
env.storage().instance().set(&DataKey::FeeConfig, &config);
476-
env.events()
477-
.publish((Symbol::new(&env, "FeeConfigUpdated"),), config);
480+
481+
let current_time = env.ledger().timestamp();
482+
let execute_after = current_time.saturating_add(TIMELOCK_DELAY_SECONDS);
483+
484+
let queued = QueuedFeeConfig {
485+
config: config.clone(),
486+
execute_after,
487+
};
488+
489+
env.storage().instance().set(&DataKey::QueuedFeeConfig, &queued);
490+
491+
env.events().publish(
492+
(Symbol::new(&env, "fee_config_queued"),),
493+
(config, execute_after),
494+
);
495+
}
496+
497+
pub fn execute_fee_config(env: Env) -> Result<(), Error> {
498+
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
499+
admin.require_auth();
500+
501+
let queued: QueuedFeeConfig = env.storage()
502+
.instance()
503+
.get(&DataKey::QueuedFeeConfig)
504+
.ok_or(Error::PreviewUnavailable)?;
505+
506+
let current_time = env.ledger().timestamp();
507+
if current_time < queued.execute_after {
508+
return Err(Error::TimelockNotElapsed);
509+
}
510+
511+
env.storage().instance().set(&DataKey::FeeConfig, &queued.config);
512+
env.storage().instance().remove(&DataKey::QueuedFeeConfig);
513+
514+
env.events().publish(
515+
(Symbol::new(&env, "FeeConfigUpdated"),),
516+
queued.config,
517+
);
518+
519+
Ok(())
478520
}
479521

480522
pub fn get_fee_config(env: Env) -> FeeConfig {
@@ -489,26 +531,61 @@ impl PortfolioRebalancer {
489531
})
490532
}
491533

492-
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) {
534+
pub fn queue_upgrade(env: Env, new_wasm_hash: BytesN<32>) {
535+
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
536+
admin.require_auth();
537+
538+
let current_time = env.ledger().timestamp();
539+
let execute_after = current_time.saturating_add(TIMELOCK_DELAY_SECONDS);
540+
541+
let queued = QueuedUpgrade {
542+
new_wasm_hash: new_wasm_hash.clone(),
543+
execute_after,
544+
};
545+
546+
env.storage().instance().set(&DataKey::QueuedUpgrade, &queued);
547+
548+
env.events().publish(
549+
(Symbol::new(&env, "upgrade_queued"),),
550+
(new_wasm_hash, execute_after),
551+
);
552+
}
553+
554+
pub fn execute_upgrade(env: Env) -> Result<(), Error> {
493555
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
494556
admin.require_auth();
557+
558+
let queued: QueuedUpgrade = env.storage()
559+
.instance()
560+
.get(&DataKey::QueuedUpgrade)
561+
.ok_or(Error::PreviewUnavailable)?;
562+
563+
let current_time = env.ledger().timestamp();
564+
if current_time < queued.execute_after {
565+
return Err(Error::TimelockNotElapsed);
566+
}
567+
495568
let current_hash: Option<BytesN<32>> = env.storage().instance().get(&DataKey::WasmHash);
496569
env.storage()
497570
.instance()
498571
.set(&DataKey::UpgradeAuthority, &admin);
499572
env.deployer()
500-
.update_current_contract_wasm(new_wasm_hash.clone());
573+
.update_current_contract_wasm(queued.new_wasm_hash.clone());
501574
env.storage()
502575
.instance()
503-
.set(&DataKey::WasmHash, &new_wasm_hash);
576+
.set(&DataKey::WasmHash, &queued.new_wasm_hash);
577+
env.storage().instance().remove(&DataKey::QueuedUpgrade);
578+
504579
env.events().publish(
505580
("portfolio", "upgraded"),
506581
UpgradeEvent {
507582
from_hash: current_hash.unwrap_or(BytesN::from_array(&env, &[0u8; 32])),
508-
to_hash: new_wasm_hash,
583+
to_hash: queued.new_wasm_hash,
509584
timestamp: env.ledger().timestamp(),
510585
},
511586
);
587+
588+
Ok(())
512589
}
513590

514591
pub fn min_rebalance_threshold(_env: Env) -> u32 {
@@ -703,6 +780,68 @@ impl PortfolioRebalancer {
703780
}
704781
}
705782

783+
pub fn set_circuit_breaker_config(
784+
env: Env,
785+
portfolio_id: u64,
786+
spike_threshold_bps: u32,
787+
window_seconds: u64,
788+
) -> Result<(), Error> {
789+
let mut portfolio = Self::load_portfolio(&env, portfolio_id)?;
790+
791+
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
792+
let caller_is_admin = env.auth().is_authorized(&admin);
793+
let caller_is_owner = env.auth().is_authorized(&portfolio.user);
794+
795+
if !caller_is_admin && !caller_is_owner {
796+
return Err(Error::PortfolioNotFound);
797+
}
798+
799+
portfolio.circuit_breaker_config = CircuitBreakerConfig {
800+
spike_threshold_bps,
801+
window_seconds,
802+
};
803+
804+
env.storage()
805+
.persistent()
806+
.set(&DataKey::Portfolio(portfolio_id), &portfolio);
807+
808+
env.events().publish(
809+
(Symbol::new(&env, "circuit_breaker_config_updated"),),
810+
(portfolio_id, spike_threshold_bps, window_seconds),
811+
);
812+
813+
Ok(())
814+
}
815+
816+
pub fn set_global_max_slippage(
817+
env: Env,
818+
portfolio_id: u64,
819+
global_max_slippage_bps: u32,
820+
) -> Result<(), Error> {
821+
let mut portfolio = Self::load_portfolio(&env, portfolio_id)?;
822+
823+
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
824+
let caller_is_admin = env.auth().is_authorized(&admin);
825+
let caller_is_owner = env.auth().is_authorized(&portfolio.user);
826+
827+
if !caller_is_admin && !caller_is_owner {
828+
return Err(Error::PortfolioNotFound);
829+
}
830+
831+
portfolio.global_max_slippage_bps = global_max_slippage_bps;
832+
833+
env.storage()
834+
.persistent()
835+
.set(&DataKey::Portfolio(portfolio_id), &portfolio);
836+
837+
env.events().publish(
838+
(Symbol::new(&env, "global_max_slippage_updated"),),
839+
(portfolio_id, global_max_slippage_bps),
840+
);
841+
842+
Ok(())
843+
}
844+
706845
pub fn get_portfolio_value_usd(
707846
env: Env,
708847
portfolio_id: u64,
@@ -837,6 +976,22 @@ impl PortfolioRebalancer {
837976

838977
let preview = portfolio::build_rebalance_preview(env, &portfolio, &reflector_client)?;
839978

979+
let mut current_prices = Map::new(env);
980+
for (asset, _) in portfolio.target_allocations.iter() {
981+
if let Some(price_data) =
982+
reflector_client.lastprice(&crate::reflector::Asset::Stellar(asset.clone()))
983+
{
984+
current_prices.set(asset, price_data.price);
985+
}
986+
}
987+
988+
crate::circuit_breaker::check_volatility(
989+
env,
990+
&portfolio.circuit_breaker_config,
991+
&reflector_client,
992+
&current_prices,
993+
)?;
994+
840995
for (asset, _) in portfolio.target_allocations.iter() {
841996
if let Some(reason) = preview.skip_reasons.get(asset) {
842997
match reason {
@@ -878,6 +1033,7 @@ impl PortfolioRebalancer {
8781033
};
8791034

8801035
if total_value > 0 {
1036+
let mut total_slippage_bps = 0i128;
8811037
for (asset, target_pct) in portfolio.target_allocations.iter() {
8821038
let price_data = reflector_client
8831039
.lastprice(&crate::reflector::Asset::Stellar(asset.clone()))
@@ -901,11 +1057,21 @@ impl PortfolioRebalancer {
9011057
let diff = expected_balance - actual_balance;
9021058
let diff_abs = if diff >= 0 { diff } else { -diff };
9031059
let slippage_bps = (diff_abs * 10000) / expected_abs;
1060+
1061+
// Per-asset slippage check (existing behavior)
9041062
if slippage_bps > snapshot.slippage_tolerance as i128 {
9051063
return Err(Error::SlippageExceeded);
9061064
}
1065+
1066+
// Accumulate for global slippage check
1067+
total_slippage_bps += slippage_bps;
9071068
}
9081069
}
1070+
1071+
// Global slippage cap check across all legs
1072+
if total_slippage_bps > portfolio.global_max_slippage_bps as i128 {
1073+
return Err(Error::SlippageExceeded);
1074+
}
9091075
}
9101076
}
9111077

@@ -962,6 +1128,60 @@ impl PortfolioRebalancer {
9621128
pub fn get_nav_history(env: Env, portfolio_id: u64, limit: u32) -> Result<Vec<NavSnapshot>, Error> {
9631129
nav::get_nav_history(&env, portfolio_id, limit)
9641130
}
1131+
1132+
pub fn close_portfolio(env: Env, portfolio_id: u64) -> Result<(), Error> {
1133+
let portfolio = Self::load_portfolio(&env, portfolio_id)?;
1134+
1135+
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
1136+
let caller_is_admin = env.auth().is_authorized(&admin);
1137+
let caller_is_owner = env.auth().is_authorized(&portfolio.user);
1138+
1139+
if !caller_is_admin && !caller_is_owner {
1140+
return Err(Error::PortfolioNotFound);
1141+
}
1142+
1143+
// Sweep all asset balances to the owner
1144+
let mut swept_amounts = Map::new(&env);
1145+
for (asset, balance) in portfolio.current_balances.iter() {
1146+
if balance > 0 {
1147+
let token_client = token::Client::new(&env, &asset);
1148+
token_client.transfer(
1149+
&env.current_contract_address(),
1150+
&portfolio.user,
1151+
&balance,
1152+
);
1153+
swept_amounts.set(asset, balance);
1154+
}
1155+
}
1156+
1157+
// Remove portfolio storage
1158+
env.storage()
1159+
.persistent()
1160+
.remove(&DataKey::Portfolio(portfolio_id));
1161+
1162+
// Remove steward if exists
1163+
env.storage()
1164+
.persistent()
1165+
.remove(&DataKey::Steward(portfolio_id));
1166+
1167+
// Remove DCA config if exists
1168+
env.storage()
1169+
.persistent()
1170+
.remove(&DataKey::DCAConfig(portfolio_id));
1171+
1172+
// Remove NAV history if exists
1173+
env.storage()
1174+
.persistent()
1175+
.remove(&DataKey::NavHistory(portfolio_id));
1176+
1177+
// Emit portfolio_closed event
1178+
env.events().publish(
1179+
(Symbol::new(&env, "portfolio_closed"),),
1180+
(portfolio_id, portfolio.user, swept_amounts),
1181+
);
1182+
1183+
Ok(())
1184+
}
9651185
}
9661186

9671187
fn require_admin(env: &Env) {

0 commit comments

Comments
 (0)