diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index 4a43874..f33d98b 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -46,6 +46,8 @@ pub enum AccessControlError { ThresholdNotMet = 25, Unauthorized = 26, UpgradeTimelockNotElapsed = 27, + ProposalCancelled = 28, + ParameterProposalCancelled = 29, } impl From for AccessControlError { @@ -438,6 +440,7 @@ impl AccessControlContract { proposer: proposer.clone(), approvals, executed: false, + cancelled: false, created_at: env.ledger().timestamp(), expires_at: env.ledger().timestamp() + PROPOSAL_TTL_LEDGERS, }; @@ -504,6 +507,9 @@ impl AccessControlContract { if proposal.executed { return Err(AccessControlError::ProposalAlreadyExecuted); } + if proposal.cancelled { + return Err(AccessControlError::ProposalCancelled); + } if env.ledger().timestamp() > proposal.expires_at { return Err(AccessControlError::ProposalExpired); } @@ -570,6 +576,9 @@ impl AccessControlContract { if proposal.executed { return Err(AccessControlError::ProposalAlreadyExecuted); } + if proposal.cancelled { + return Err(AccessControlError::ProposalCancelled); + } if env.ledger().timestamp() > proposal.expires_at { return Err(AccessControlError::ProposalExpired); } @@ -643,6 +652,42 @@ impl AccessControlContract { Ok(()) } + /// Cancel a proposal before execution. Only the proposer or a quorum of signers may cancel. + pub fn cancel_action( + env: Env, + canceller: Address, + proposal_id: u64, + ) -> Result<(), AccessControlError> { + canceller.require_auth(); + let config = Self::load_multisig_config(&env)?; + Self::require_signer(&config, &canceller)?; + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(AccessControlError::ProposalNotFound)?; + + if proposal.executed { + return Err(AccessControlError::ProposalAlreadyExecuted); + } + if proposal.cancelled { + return Err(AccessControlError::ProposalCancelled); + } + + if proposal.proposer != canceller && proposal.approvals.len() < config.threshold { + return Err(AccessControlError::Unauthorized); + } + + proposal.cancelled = true; + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + Self::bump_persistent(&env, &DataKey::Proposal(proposal_id)); + + Ok(()) + } + /// Get a proposal by ID. /// /// **Parameters:** @@ -707,6 +752,7 @@ impl AccessControlContract { approvals, created_at: env.ledger().timestamp(), executed: false, + cancelled: false, }; env.storage() @@ -762,6 +808,9 @@ impl AccessControlContract { if proposal.executed { return Err(AccessControlError::ParameterProposalAlreadyExecuted); } + if proposal.cancelled { + return Err(AccessControlError::ParameterProposalCancelled); + } for i in 0..proposal.approvals.len() { if proposal.approvals.get(i).unwrap() == signer { return Err(KoraError::AlreadyInitialized); @@ -802,6 +851,9 @@ impl AccessControlContract { if proposal.executed { return Err(AccessControlError::ParameterProposalAlreadyExecuted); } + if proposal.cancelled { + return Err(AccessControlError::ParameterProposalCancelled); + } if proposal.approvals.len() < config.threshold { return Err(AccessControlError::GovernanceThresholdNotMet); } @@ -827,6 +879,42 @@ impl AccessControlContract { Ok(()) } + /// Cancel a parameter-change proposal before execution. Only the proposer or a quorum of signers may cancel. + pub fn cancel_parameter_change( + env: Env, + canceller: Address, + proposal_id: u64, + ) -> Result<(), AccessControlError> { + canceller.require_auth(); + let config = Self::load_multisig_config(&env)?; + Self::require_signer(&config, &canceller)?; + + let mut proposal: ParameterProposal = env + .storage() + .persistent() + .get(&DataKey::ParameterProposal(proposal_id)) + .ok_or(AccessControlError::ParameterProposalNotFound)?; + + if proposal.executed { + return Err(AccessControlError::ParameterProposalAlreadyExecuted); + } + if proposal.cancelled { + return Err(AccessControlError::ParameterProposalCancelled); + } + + if proposal.proposer != canceller && proposal.approvals.len() < config.threshold { + return Err(AccessControlError::Unauthorized); + } + + proposal.cancelled = true; + env.storage() + .persistent() + .set(&DataKey::ParameterProposal(proposal_id), &proposal); + Self::bump_persistent(&env, &DataKey::ParameterProposal(proposal_id)); + + Ok(()) + } + /// Read the current governed value of a parameter, if one has been executed. /// /// **Parameters:** diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index 8012f3d..6d4625c 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -1,8 +1,9 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Symbol}; const MAX_STALENESS_SECS: u64 = 3600; +const UPGRADE_TIMELOCK_DELAY: u64 = 86_400; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -14,6 +15,9 @@ pub enum PriceOracleError { InvoiceExpired = 4, NotAdmin = 5, NotInitialized = 6, + NoUpgradeProposed = 7, + UpgradeTimelockNotElapsed = 8, + ProtocolPaused = 9, } #[contracttype] @@ -26,7 +30,9 @@ pub struct PriceData { #[contracttype] pub enum DataKey { Admin, + AccessControl, Price(Symbol, Symbol), + UpgradeProposal, } #[contract] @@ -34,16 +40,27 @@ pub struct PriceOracleContract; #[contractimpl] impl PriceOracleContract { - pub fn initialize(env: Env, admin: Address) -> Result<(), PriceOracleError> { + pub fn initialize(env: Env, admin: Address, access_control: Address) -> Result<(), PriceOracleError> { if env.storage().instance().has(&DataKey::Admin) { return Err(PriceOracleError::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::AccessControl, &access_control); + Ok(()) + } + + /// Set the access_control contract address. Admin only. + /// Used for post-deployment wiring or migration. + pub fn set_access_control(env: Env, admin: Address, access_control: Address) -> Result<(), PriceOracleError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage().instance().set(&DataKey::AccessControl, &access_control); Ok(()) } /// Set a price for a currency pair. Admin only. /// Price is expressed as `base` units per 1 unit of `quote`, scaled by 1e7 (stroops). + /// Blocked when the protocol is paused. pub fn set_price( env: Env, admin: Address, @@ -53,6 +70,7 @@ impl PriceOracleContract { ) -> Result<(), PriceOracleError> { admin.require_auth(); Self::require_admin(&env, &admin)?; + Self::require_not_paused(&env)?; if price <= 0 { return Err(PriceOracleError::InvalidAmount); @@ -118,6 +136,45 @@ impl PriceOracleContract { Ok(converted) } + /// Transfer admin rights to a new address. Admin only. + pub fn transfer_admin(env: Env, admin: Address, new_admin: Address) -> Result<(), PriceOracleError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage().instance().set(&DataKey::Admin, &new_admin); + Ok(()) + } + + /// Propose a WASM upgrade with a 24-hour timelock. Admin only. + pub fn propose_upgrade( + env: Env, + admin: Address, + new_wasm_hash: BytesN<32>, + ) -> Result<(), PriceOracleError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage() + .instance() + .set(&DataKey::UpgradeProposal, &(new_wasm_hash, env.ledger().timestamp())); + Ok(()) + } + + /// Execute a previously proposed upgrade after the 24-hour timelock. + pub fn execute_upgrade(env: Env, admin: Address) -> Result<(), PriceOracleError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + let (wasm_hash, proposed_at): (BytesN<32>, u64) = env + .storage() + .instance() + .get(&DataKey::UpgradeProposal) + .ok_or(PriceOracleError::NoUpgradeProposed)?; + if env.ledger().timestamp() < proposed_at + UPGRADE_TIMELOCK_DELAY { + return Err(PriceOracleError::UpgradeTimelockNotElapsed); + } + env.storage().instance().remove(&DataKey::UpgradeProposal); + env.deployer().update_current_contract_wasm(wasm_hash); + Ok(()) + } + fn require_admin(env: &Env, caller: &Address) -> Result<(), PriceOracleError> { let admin: Address = env .storage() @@ -129,6 +186,25 @@ impl PriceOracleContract { } Ok(()) } + + fn require_not_paused(env: &Env) -> Result<(), PriceOracleError> { + let access_control: Address = env + .storage() + .instance() + .get(&DataKey::AccessControl) + .ok_or(PriceOracleError::NotInitialized)?; + + let is_paused: bool = env.invoke_contract( + &access_control, + &soroban_sdk::Symbol::new(env, "is_paused"), + soroban_sdk::vec![env], + ); + + if is_paused { + return Err(PriceOracleError::ProtocolPaused); + } + Ok(()) + } } #[cfg(test)] @@ -142,7 +218,8 @@ mod tests { let contract_id = env.register_contract(None, PriceOracleContract); let client = PriceOracleContractClient::new(&env, &contract_id); let admin = Address::generate(&env); - client.initialize(&admin); + let access_control = Address::generate(&env); + client.initialize(&admin, &access_control); (env, admin, client) } @@ -206,4 +283,99 @@ mod tests { let result = client.try_get_price(&base, "e); assert!(result.is_err()); } + + #[test] + fn test_transfer_admin_success() { + let (env, admin, client) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + let result = client.try_set_price(&new_admin, &Symbol::new(&env, "XLM"), &Symbol::new(&env, "USDC"), &10_000_000i128); + assert!(result.is_ok()); + } + + #[test] + fn test_transfer_admin_requires_admin() { + let (env, admin, client) = setup(); + let stranger = Address::generate(&env); + let new_admin = Address::generate(&env); + let result = client.try_transfer_admin(&stranger, &new_admin); + assert!(result.is_err()); + } + + #[test] + fn test_propose_upgrade_success() { + let (env, admin, client) = setup(); + let wasm_hash = soroban_sdk::BytesN::<32>::from_array(&env, &[0u8; 32]); + let result = client.try_propose_upgrade(&admin, &wasm_hash); + assert!(result.is_ok()); + } + + #[test] + fn test_execute_upgrade_requires_timelock() { + let (env, admin, client) = setup(); + let wasm_hash = soroban_sdk::BytesN::<32>::from_array(&env, &[0u8; 32]); + client.propose_upgrade(&admin, &wasm_hash); + let result = client.try_execute_upgrade(&admin); + assert!(result.is_err()); + } + + #[test] + fn test_execute_upgrade_success() { + use soroban_sdk::testutils::{Ledger, LedgerInfo}; + let (env, admin, client) = setup(); + let wasm_hash = soroban_sdk::BytesN::<32>::from_array(&env, &[1u8; 32]); + client.propose_upgrade(&admin, &wasm_hash); + + env.ledger().set(LedgerInfo { + timestamp: env.ledger().timestamp() + UPGRADE_TIMELOCK_DELAY + 1, + protocol_version: 21, + sequence_number: 2, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1000, + min_persistent_entry_ttl: 1000, + max_entry_ttl: 100_000, + }); + + let result = client.try_execute_upgrade(&admin); + assert!(result.is_ok()); + } + + #[test] + fn test_execute_upgrade_no_proposal() { + let (env, admin, client) = setup(); + let result = client.try_execute_upgrade(&admin); + assert!(result.is_err()); + } + + #[test] + fn test_set_access_control() { + let (env, admin, client) = setup(); + let new_access_control = Address::generate(&env); + client.set_access_control(&admin, &new_access_control).unwrap(); + } + + #[test] + fn test_set_price_when_paused_fails() { + let (env, admin, client) = setup(); + let access_control = Address::generate(&env); + env.storage().instance().set(&soroban_sdk::symbol_short!("AC"), &true); + + let result = client.try_set_price( + &admin, + &Symbol::new(&env, "EURC"), + &Symbol::new(&env, "USDC"), + &11_000_000i128, + ); + assert!(result.is_err()); + } + + #[test] + fn test_set_price_when_not_paused_succeeds() { + let (env, admin, client) = setup(); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + let result = client.try_set_price(&admin, &base, "e, &11_000_000i128); + assert!(result.is_ok()); + } } diff --git a/contracts/shared/src/types.rs b/contracts/shared/src/types.rs index 6a6e6c7..7e9f915 100644 --- a/contracts/shared/src/types.rs +++ b/contracts/shared/src/types.rs @@ -285,6 +285,7 @@ pub struct Proposal { pub proposer: Address, pub approvals: Vec
, pub executed: bool, + pub cancelled: bool, pub created_at: u64, pub expires_at: u64, } @@ -319,6 +320,7 @@ pub struct ParameterProposal { pub approvals: Vec
, // signers that have voted in favour pub created_at: u64, pub executed: bool, + pub cancelled: bool, } /// A multisig signer recovery proposal for lost-key scenarios.