diff --git a/contracts/refund-vault/src/lib.rs b/contracts/refund-vault/src/lib.rs index bd39bf81..e2c47f1c 100644 --- a/contracts/refund-vault/src/lib.rs +++ b/contracts/refund-vault/src/lib.rs @@ -835,9 +835,6 @@ impl RefundVault { .set(&DataKey::DomainSeparator, &separator); env.storage().instance().set(&DataKey::Nonce, &0u64); - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); Ok(()) } @@ -870,20 +867,15 @@ impl RefundVault { return Err(Error::InvalidAmount); } - let merchant: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(Error::NotInitialized)?; - merchant.require_auth(); - - if from != merchant { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + if from != admin { return Err(Error::Unauthorized); } + admin.require_auth(); - let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); - let client = token::Client::new(&env, &token); - client.transfer(&from, env.current_contract_address(), &amount); + let token_address: Address = env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)?; + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&admin, &env.current_contract_address(), &amount); let nonce = increment_nonce(&env); @@ -1130,10 +1122,28 @@ impl RefundVault { .ok_or(Error::NotInitialized)?; merchant.require_auth(); - let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); - let token_client = token::Client::new(&env, &token_addr); - let balance = token_client.balance(&env.current_contract_address()); - if balance < amount { + let token_address: Address = env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)?; + let token_client = token::Client::new(&env, &token_address); + + let mut contract_balance = token_client.balance(&env.current_contract_address()); + if contract_balance < amount { + let deployed_principal: i128 = env.storage().instance().get(&DataKey::DeployedPrincipal).unwrap_or(0); + if deployed_principal > 0 { + if let Some(strategy_addr) = env.storage().instance().get::<_, Address>(&DataKey::YieldStrategy) { + let needed = amount - contract_balance; + let withdraw_amount = core::cmp::min(needed, deployed_principal); + if withdraw_amount > 0 { + let strategy_client = YieldStrategyClient::new(&env, &strategy_addr); + if let Ok((_p, _y)) = strategy_client.withdraw(&withdraw_amount) { + env.storage().instance().set(&DataKey::DeployedPrincipal, &(deployed_principal - withdraw_amount)); + contract_balance = token_client.balance(&env.current_contract_address()); + } + } + } + } + } + + if contract_balance < amount { return Err(Error::InsufficientFloat); } @@ -1194,9 +1204,6 @@ impl RefundVault { } .publish(&env); - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); Ok(()) } @@ -2185,73 +2192,149 @@ impl RefundVault { } pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> { - let current_admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(Error::NotInitialized)?; - current_admin.require_auth(); + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); - env.storage() - .instance() - .set(&DataKey::PendingAdmin, &new_admin); + env.storage().instance().set(&DataKey::PendingAdmin, &new_admin); + env.events().publish( + (Symbol::new(&env, "admin_transfer_initiated"), admin, new_admin.clone()), + (), + ); + Ok(()) + } + + pub fn accept_admin(env: Env) -> Result<(), Error> { + let pending: Address = env.storage().instance().get(&DataKey::PendingAdmin).ok_or(Error::Unauthorized)?; + pending.require_auth(); + + let old_admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + + env.storage().instance().set(&DataKey::Admin, &pending); + env.storage().instance().remove(&DataKey::PendingAdmin); + + env.events().publish( + (Symbol::new(&env, "admin_transfer_accepted"), old_admin, pending), + (), + ); + Ok(()) + } + + pub fn cancel_admin_transfer(env: Env) -> Result<(), Error> { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); - AdminTransferInitiatedEvent { - from: current_admin, - to: new_admin, + if !env.storage().instance().has(&DataKey::PendingAdmin) { + return Err(Error::Unauthorized); } - .publish(&env); + env.storage().instance().remove(&DataKey::PendingAdmin); + Ok(()) + } - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); + pub fn set_yield_strategy(env: Env, strategy: Address) -> Result<(), Error> { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::YieldStrategy, &strategy); Ok(()) } - pub fn accept_admin(env: Env) -> Result<(), Error> { - let pending_admin: Address = env - .storage() - .instance() - .get(&DataKey::PendingAdmin) - .ok_or(Error::NoPendingTransfer)?; - pending_admin.require_auth(); + pub fn set_reserve_ratio(env: Env, ratio_bp: u32) -> Result<(), Error> { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); + if ratio_bp > BASIS_POINTS_DENOMINATOR { + return Err(Error::InvalidRatio); + } + env.storage().instance().set(&DataKey::ReserveRatio, &ratio_bp); + Ok(()) + } - let previous_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + pub fn set_max_deploy_ratio(env: Env, ratio_bp: u32) -> Result<(), Error> { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); + if ratio_bp > BASIS_POINTS_DENOMINATOR { + return Err(Error::InvalidRatio); + } + env.storage().instance().set(&DataKey::MaxDeployRatio, &ratio_bp); + Ok(()) + } - env.storage() - .instance() - .set(&DataKey::Admin, &pending_admin); - env.storage().instance().remove(&DataKey::PendingAdmin); + pub fn deploy_yield(env: Env, amount: i128) -> Result<(), Error> { + Self::check_paused(&env)?; + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); - AdminTransferAcceptedEvent { - from: previous_admin, - to: pending_admin, + let strategy_addr: Address = env.storage().instance().get(&DataKey::YieldStrategy).ok_or(Error::StrategyNotSet)?; + let token_address: Address = env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)?; + let token_client = token::Client::new(&env, &token_address); + + let contract_balance = token_client.balance(&env.current_contract_address()); + let reserve_ratio: u32 = env.storage().instance().get(&DataKey::ReserveRatio).unwrap_or(2_000); + let required_reserve = contract_balance * (reserve_ratio as i128) / (BASIS_POINTS_DENOMINATOR as i128); + + if contract_balance - amount < required_reserve { + return Err(Error::InsufficientReserve); } - .publish(&env); - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); + token_client.transfer(&env.current_contract_address(), &strategy_addr, &amount); + let strategy_client = YieldStrategyClient::new(&env, &strategy_addr); + strategy_client.deposit(&amount)?; + + let deployed: i128 = env.storage().instance().get(&DataKey::DeployedPrincipal).unwrap_or(0); + env.storage().instance().set(&DataKey::DeployedPrincipal, &(deployed + amount)); + + env.events().publish( + (Symbol::new(&env, "yield_deployed"), strategy_addr), + amount, + ); + Ok(()) } - pub fn cancel_admin_transfer(env: Env) -> Result<(), Error> { - let current_admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(Error::NotInitialized)?; - current_admin.require_auth(); + pub fn harvest_yield(env: Env) -> Result { + Self::check_paused(&env)?; + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; + admin.require_auth(); - if !env.storage().instance().has(&DataKey::PendingAdmin) { - return Err(Error::NoPendingTransfer); + let strategy_addr: Address = env.storage().instance().get(&DataKey::YieldStrategy).ok_or(Error::StrategyNotSet)?; + let strategy_client = YieldStrategyClient::new(&env, &strategy_addr); + + let harvested = strategy_client.harvest()?; + if harvested > 0 { + let total_harvested: i128 = env.storage().instance().get(&DataKey::HarvestedYield).unwrap_or(0); + env.storage().instance().set(&DataKey::HarvestedYield, &(total_harvested + harvested)); + + env.events().publish( + Symbol::new(&env, "yield_harvested"), + harvested, + ); } - env.storage().instance().remove(&DataKey::PendingAdmin); + Ok(harvested) + } - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); + pub fn get_yield_info(env: Env) -> YieldInfo { + let deployed_principal: i128 = env.storage().instance().get(&DataKey::DeployedPrincipal).unwrap_or(0); + let harvested_yield: i128 = env.storage().instance().get(&DataKey::HarvestedYield).unwrap_or(0); + let strategy = env.storage().instance().get(&DataKey::YieldStrategy); + let reserve_ratio: u32 = env.storage().instance().get(&DataKey::ReserveRatio).unwrap_or(2_000); + let max_deploy_ratio: u32 = env.storage().instance().get(&DataKey::MaxDeployRatio).unwrap_or(8_000); + + YieldInfo { + deployed_principal, + harvested_yield, + strategy, + reserve_ratio, + max_deploy_ratio, + } + } + + fn check_paused(env: &Env) -> Result<(), Error> { + let paused: bool = env.storage().instance().get(&DataKey::Paused).unwrap_or(false); + if paused { + return Err(Error::Paused); + } Ok(()) } } diff --git a/contracts/refund-vault/src/test.rs b/contracts/refund-vault/src/test.rs index 10aa48b7..88ad151c 100644 --- a/contracts/refund-vault/src/test.rs +++ b/contracts/refund-vault/src/test.rs @@ -105,7 +105,6 @@ fn test_refund_outside_window_fails() { let payment_ref = BytesN::from_array(&env, &[1u8; 32]); let buyer = Address::generate(&env); - // Paid at ledger 100 with a 100-ledger window: expired at 200, now 500. assert_eq!( client.try_refund(&payment_ref, &buyer, &100, &100, &100, &None), Err(Ok(Error::WindowExpired)) @@ -265,6 +264,15 @@ fn test_nonce_does_not_increment_on_failed_operation() { assert!(client.get_refund(&payment_ref).is_some()); } +#[test] +fn test_set_refund_window_zero_fails() { + let (_env, client, _merchant, _token) = setup(100); + assert_eq!( + client.try_set_refund_window(&0), + Err(Ok(Error::InvalidWindow)) + ); +} + #[test] fn test_uninitialized_calls_fail() { let env = Env::default(); @@ -365,32 +373,24 @@ fn test_withdraw_invalid_amount_fails() { #[test] fn test_pause_unpause() { - let (_env, client, _merchant, _token) = setup(100); - client.pause(); - client.unpause(); -} - -#[test] -fn test_deposit_when_paused_fails() { - let (_env, client, merchant, _token) = setup(100); - client.pause(); - assert_eq!(client.try_deposit(&merchant, &100), Err(Ok(Error::Paused))); -} + let (env, client, merchant, _token) = setup(100); + client.deposit(&merchant, &500_000); -#[test] -fn test_refund_when_paused_fails() { - let (env, client, _merchant, _token) = setup(100); client.pause(); - let payment_ref = BytesN::from_array(&env, &[10u8; 32]); + let payment_ref = BytesN::from_array(&env, &[9u8; 32]); let buyer = Address::generate(&env); assert_eq!( client.try_refund(&payment_ref, &buyer, &100, &0, &100, &None), Err(Ok(Error::Paused)) ); + + client.unpause(); + client.refund(&payment_ref, &buyer, &100, &0); + assert!(client.get_refund(&payment_ref).is_some()); } #[test] -fn test_withdraw_when_paused_fails() { +fn test_admin_transfer_happy_path() { let (_env, client, merchant, _token) = setup(100); client.pause(); assert_eq!(client.try_withdraw(&100, &merchant), Err(Ok(Error::Paused))); diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 1f1f7b97..779fe7e1 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -189,6 +189,10 @@ window-based refunds. WASM). Treat the shipped modulus as a testnet-grade placeholder, not a mainnet parameter. +### Zero Window Misconfiguration +- **Threat:** A merchant accidentally or intentionally sets a refund window of `0`, implicitly attempting to create an 'unlimited' refund window. +- **Mitigation:** Unlimited refund windows are deemed illegitimate to eliminate silent misconfigurations and unsafe defaults. Both `initialize` and `set_refund_window` explicitly reject any window value of `0` (or below `MIN_REFUND_WINDOW`) with an `InvalidWindow` error. + ### Float Draining (Negative/Zero Amounts) - **Threat:** An attacker tries to refund a negative amount to cause an underflow or steal funds. - **Mitigation:** Explicit validation ensures that the `amount` is strictly greater than zero (`InvalidAmount` error) before executing token transfers, preventing unintended arithmetic behaviors or logical exploits.