@@ -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
9671187fn require_admin ( env : & Env ) {
0 commit comments