From 14378aed323cf0ce749f12332953d1be12799bc6 Mon Sep 17 00:00:00 2001 From: Admailo Date: Mon, 27 Jul 2026 16:14:14 +0100 Subject: [PATCH 1/3] feat: Add transfer_admin and upgrade mechanism to price_oracle (#497, #496) --- contracts/price_oracle/src/lib.rs | 109 +++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index cde0e38..cf81a02 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,8 @@ pub enum PriceOracleError { InvoiceExpired = 4, NotAdmin = 5, NotInitialized = 6, + NoUpgradeProposed = 7, + UpgradeTimelockNotElapsed = 8, } #[contracttype] @@ -27,6 +30,7 @@ pub struct PriceData { pub enum DataKey { Admin, Price(Symbol, Symbol), + UpgradeProposal, } #[contract] @@ -117,6 +121,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() @@ -205,4 +248,68 @@ 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()); + } } From 50314f18c991318be70ddfa98a5e79f4bfcedff9 Mon Sep 17 00:00:00 2001 From: Admailo Date: Mon, 27 Jul 2026 16:18:33 +0100 Subject: [PATCH 2/3] feat: Add proposal cancellation/veto mechanism to access_control (#495) --- contracts/access_control/src/lib.rs | 175 ++++++++++++++++++++++++++++ contracts/shared/src/types.rs | 2 + 2 files changed, 177 insertions(+) diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index 256fa4b..bf6bfec 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 { @@ -435,6 +437,7 @@ impl AccessControlContract { proposer: proposer.clone(), approvals, executed: false, + cancelled: false, created_at: env.ledger().timestamp(), expires_at: env.ledger().timestamp() + PROPOSAL_TTL_LEDGERS, }; @@ -484,6 +487,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); } @@ -535,6 +541,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); } @@ -590,6 +599,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:** @@ -651,6 +696,7 @@ impl AccessControlContract { approvals, created_at: env.ledger().timestamp(), executed: false, + cancelled: false, }; env.storage() @@ -701,6 +747,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(AccessControlError::AlreadyVoted); @@ -740,6 +789,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); } @@ -763,6 +815,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:** @@ -1800,4 +1888,91 @@ mod tests { assert!(client.has_role(&target1, &Role::Verifier), "Re-granted role should be assigned"); assert!(client.has_role(&target2, &Role::Operator), "Other role should be unaffected"); } + + // ── Proposal cancellation ────────────────────────────────────────────── + + #[test] + fn test_cancel_action_by_proposer() { + let (env, admin, client) = setup(); + let signers = soroban_sdk::vec![&env, admin.clone()]; + client.configure_multisig(&admin, &signers, 1); + + let action = AdminAction::Pause; + let proposal_id = client.propose_action(&admin, &action).unwrap(); + + client.cancel_action(&admin, proposal_id).unwrap(); + let proposal = client.get_proposal(proposal_id).unwrap(); + assert!(proposal.cancelled); + } + + #[test] + fn test_cannot_execute_cancelled_proposal() { + let (env, admin, client) = setup(); + let signers = soroban_sdk::vec![&env, admin.clone()]; + client.configure_multisig(&admin, &signers, 1); + + let action = AdminAction::Pause; + let proposal_id = client.propose_action(&admin, &action).unwrap(); + client.cancel_action(&admin, proposal_id).unwrap(); + + assert!(client.try_execute_action(&admin, proposal_id).is_err()); + } + + #[test] + fn test_cannot_approve_cancelled_proposal() { + let (env, admin, client) = setup(); + let signer2 = Address::generate(&env); + let signers = soroban_sdk::vec![&env, admin.clone(), signer2.clone()]; + client.configure_multisig(&admin, &signers, 2); + + let action = AdminAction::Pause; + let proposal_id = client.propose_action(&admin, &action).unwrap(); + client.cancel_action(&admin, proposal_id).unwrap(); + + assert!(client.try_approve_action(&signer2, proposal_id).is_err()); + } + + #[test] + fn test_cancel_parameter_change_by_proposer() { + let (env, admin, client) = setup(); + let signers = soroban_sdk::vec![&env, admin.clone()]; + client.configure_multisig(&admin, &signers, 1); + + let proposal_id = client + .propose_parameter_change(&admin, &ParameterKey::FeeBps, &500) + .unwrap(); + + client.cancel_parameter_change(&admin, proposal_id).unwrap(); + let proposal = client.get_parameter_proposal(proposal_id).unwrap(); + assert!(proposal.cancelled); + } + + #[test] + fn test_cannot_execute_cancelled_parameter_change() { + let (env, admin, client) = setup(); + let signers = soroban_sdk::vec![&env, admin.clone()]; + client.configure_multisig(&admin, &signers, 1); + + let proposal_id = client + .propose_parameter_change(&admin, &ParameterKey::FeeBps, &500) + .unwrap(); + client.cancel_parameter_change(&admin, proposal_id).unwrap(); + + assert!(client.try_execute_parameter_change(&admin, proposal_id).is_err()); + } + + #[test] + fn test_cannot_vote_on_cancelled_parameter_change() { + let (env, admin, client) = setup(); + let signer2 = Address::generate(&env); + let signers = soroban_sdk::vec![&env, admin.clone(), signer2.clone()]; + client.configure_multisig(&admin, &signers, 2); + + let proposal_id = client + .propose_parameter_change(&admin, &ParameterKey::FeeBps, &500) + .unwrap(); + client.cancel_parameter_change(&admin, proposal_id).unwrap(); + + assert!(client.try_vote_parameter_change(&signer2, proposal_id).is_err()); + } } diff --git a/contracts/shared/src/types.rs b/contracts/shared/src/types.rs index 2033693..dbc8335 100644 --- a/contracts/shared/src/types.rs +++ b/contracts/shared/src/types.rs @@ -282,6 +282,7 @@ pub struct Proposal { pub proposer: Address, pub approvals: Vec
, pub executed: bool, + pub cancelled: bool, pub created_at: u64, pub expires_at: u64, } @@ -316,4 +317,5 @@ pub struct ParameterProposal { pub approvals: Vec
, // signers that have voted in favour pub created_at: u64, pub executed: bool, + pub cancelled: bool, } From 597acec54605d21475b6284f4ee1ebf60a8a6141 Mon Sep 17 00:00:00 2001 From: Admailo Date: Mon, 27 Jul 2026 16:50:54 +0100 Subject: [PATCH 3/3] feat: Add pause integration to price_oracle (#498) --- contracts/price_oracle/src/lib.rs | 69 ++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index cf81a02..75a8cae 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -17,6 +17,7 @@ pub enum PriceOracleError { NotInitialized = 6, NoUpgradeProposed = 7, UpgradeTimelockNotElapsed = 8, + ProtocolPaused = 9, } #[contracttype] @@ -29,6 +30,7 @@ pub struct PriceData { #[contracttype] pub enum DataKey { Admin, + AccessControl, Price(Symbol, Symbol), UpgradeProposal, } @@ -38,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, @@ -57,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); @@ -171,6 +185,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)] @@ -184,7 +217,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) } @@ -312,4 +346,35 @@ mod tests { 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()); + } }