From fbd6a52b6a9bf0218f5c3ea845c59b646e7ca1fc Mon Sep 17 00:00:00 2001 From: BigDella <296652908+BigDella@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:05:46 +0100 Subject: [PATCH] Fix #164: add timelocked, quorum-approved WASM upgrade governance Adds a governance module to the chainmove-pool Soroban contract: - init_governance bootstraps a static approver set, quorum, and timelock - propose_upgrade requires an approver and an explicit next schema version - approve_upgrade collects distinct approvals, rejecting duplicates - execute_upgrade requires quorum + elapsed timelock + matching schema version at execution time, then calls update_current_contract_wasm and advances the persisted schema version - cancel_upgrade lets the proposer or any approver kill a pending proposal - every transition emits an auditable event No single signer can upgrade immediately; execution is impossible before quorum and timelock conditions are met; an incompatible/stale schema version fails closed before any WASM is swapped. --- contracts/chainmove-pool/src/governance.rs | 337 +++++++++++++++++ .../chainmove-pool/src/governance_test.rs | 341 ++++++++++++++++++ contracts/chainmove-pool/src/lib.rs | 124 ++++++- 3 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 contracts/chainmove-pool/src/governance.rs create mode 100644 contracts/chainmove-pool/src/governance_test.rs diff --git a/contracts/chainmove-pool/src/governance.rs b/contracts/chainmove-pool/src/governance.rs new file mode 100644 index 00000000..ee857a2d --- /dev/null +++ b/contracts/chainmove-pool/src/governance.rs @@ -0,0 +1,337 @@ +//! Timelocked, quorum-approved WASM upgrade governance for the ChainMove +//! pool contract. +//! +//! No single signer can move the contract's active WASM. An upgrade must be +//! proposed by an approver, collect approvals from at least `quorum` +//! distinct approvers, and wait until a fixed timelock elapses before it can +//! be executed. Executing an upgrade also requires the proposal's declared +//! schema migration to be compatible with the currently persisted schema +//! version, so an incompatible migration can never activate new code +//! against old state. + +use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; + +use crate::ContractError; + +/// Static governance parameters, set once at bootstrap. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GovernanceConfig { + pub approvers: Vec
, + pub quorum: u32, + pub timelock_seconds: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum ProposalStatus { + Pending, + Executed, + Canceled, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeProposal { + pub id: u64, + pub proposer: Address, + pub new_wasm_hash: BytesN<32>, + /// Schema version this proposal expects to be active when executed. + /// Execution fails if the persisted schema version has moved on. + pub current_schema_version: u32, + /// Schema version the contract will report after this upgrade lands. + /// Must be an explicit, forward migration step (`current + 1`) so a + /// migration plan can never be skipped or applied out of order. + pub new_schema_version: u32, + /// Opaque commitment to the off-chain migration plan/runbook that must + /// accompany this upgrade (e.g. a hash of the migration document). + pub migration_plan_hash: BytesN<32>, + pub proposed_at: u64, + /// Earliest ledger timestamp at which this proposal may execute. + pub eta: u64, + pub approvals: Vec
, + pub status: ProposalStatus, +} + +use crate::DataKey; + +pub fn init_governance( + env: &Env, + admin: Address, + approvers: Vec
, + quorum: u32, + timelock_seconds: u64, + initial_schema_version: u32, +) -> Result { + admin.require_auth(); + + let config_key = DataKey::GovernanceConfig; + if env.storage().instance().has(&config_key) { + return Err(ContractError::GovernanceAlreadyInitialized); + } + + if approvers.is_empty() || quorum == 0 || quorum > approvers.len() { + return Err(ContractError::InvalidGovernanceConfig); + } + if has_duplicate_address(&approvers) { + return Err(ContractError::InvalidGovernanceConfig); + } + + let config = GovernanceConfig { + approvers, + quorum, + timelock_seconds, + }; + + env.storage().instance().set(&config_key, &config); + env.storage() + .instance() + .set(&DataKey::SchemaVersion, &initial_schema_version); + env.storage() + .instance() + .set(&DataKey::NextProposalId, &1u64); + + Ok(config) +} + +pub fn read_governance_config(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::GovernanceConfig) + .ok_or(ContractError::GovernanceNotInitialized) +} + +pub fn read_schema_version(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::SchemaVersion) + .ok_or(ContractError::GovernanceNotInitialized) +} + +pub fn read_upgrade_proposal( + env: &Env, + proposal_id: u64, +) -> Result { + let key = DataKey::UpgradeProposal(proposal_id); + let proposal = env + .storage() + .persistent() + .get(&key) + .ok_or(ContractError::ProposalNotFound)?; + env.storage() + .persistent() + .extend_ttl(&key, crate::RENT_THRESHOLD, crate::RENT_EXTEND_TO); + Ok(proposal) +} + +pub fn propose_upgrade( + env: &Env, + proposer: Address, + new_wasm_hash: BytesN<32>, + new_schema_version: u32, + migration_plan_hash: BytesN<32>, +) -> Result { + proposer.require_auth(); + + let config = read_governance_config(env)?; + require_approver(&config, &proposer)?; + + let current_schema_version = read_schema_version(env)?; + // Migrations must be explicit, sequential forward steps. This is the + // guard that rejects an incompatible schema version before any WASM is + // ever swapped in. + if new_schema_version != current_schema_version + 1 { + return Err(ContractError::IncompatibleSchemaVersion); + } + + let now = env.ledger().timestamp(); + let id: u64 = env + .storage() + .instance() + .get(&DataKey::NextProposalId) + .unwrap_or(1u64); + + let mut approvals: Vec
= Vec::new(env); + approvals.push_back(proposer.clone()); + + let proposal = UpgradeProposal { + id, + proposer: proposer.clone(), + new_wasm_hash, + current_schema_version, + new_schema_version, + migration_plan_hash, + proposed_at: now, + eta: now + config.timelock_seconds, + approvals, + status: ProposalStatus::Pending, + }; + + let proposal_key = DataKey::UpgradeProposal(id); + env.storage().persistent().set(&proposal_key, &proposal); + env.storage().persistent().extend_ttl( + &proposal_key, + crate::RENT_THRESHOLD, + crate::RENT_EXTEND_TO, + ); + env.storage() + .instance() + .set(&DataKey::NextProposalId, &(id + 1)); + + publish_governance_event(env, "gov_proposed_v1", &proposal); + + Ok(proposal) +} + +pub fn approve_upgrade( + env: &Env, + approver: Address, + proposal_id: u64, +) -> Result { + approver.require_auth(); + + let config = read_governance_config(env)?; + require_approver(&config, &approver)?; + + let mut proposal = read_upgrade_proposal(env, proposal_id)?; + if proposal.status != ProposalStatus::Pending { + return Err(ContractError::ProposalNotPending); + } + if proposal.approvals.contains(&approver) { + return Err(ContractError::AlreadyApproved); + } + + proposal.approvals.push_back(approver); + let proposal_key = DataKey::UpgradeProposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); + env.storage().persistent().extend_ttl( + &proposal_key, + crate::RENT_THRESHOLD, + crate::RENT_EXTEND_TO, + ); + + publish_governance_event(env, "gov_approved_v1", &proposal); + + Ok(proposal) +} + +pub fn cancel_upgrade( + env: &Env, + canceler: Address, + proposal_id: u64, +) -> Result { + canceler.require_auth(); + + let config = read_governance_config(env)?; + let mut proposal = read_upgrade_proposal(env, proposal_id)?; + + if proposal.status != ProposalStatus::Pending { + return Err(ContractError::ProposalNotPending); + } + if canceler != proposal.proposer && !is_approver(&config, &canceler) { + return Err(ContractError::NotAnApprover); + } + + proposal.status = ProposalStatus::Canceled; + let proposal_key = DataKey::UpgradeProposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); + env.storage().persistent().extend_ttl( + &proposal_key, + crate::RENT_THRESHOLD, + crate::RENT_EXTEND_TO, + ); + + publish_governance_event(env, "gov_canceled_v1", &proposal); + + Ok(proposal) +} + +pub fn execute_upgrade( + env: &Env, + executor: Address, + proposal_id: u64, +) -> Result { + executor.require_auth(); + + let config = read_governance_config(env)?; + require_approver(&config, &executor)?; + + let mut proposal = read_upgrade_proposal(env, proposal_id)?; + if proposal.status != ProposalStatus::Pending { + return Err(ContractError::ProposalNotPending); + } + + if (proposal.approvals.len() as u32) < config.quorum { + return Err(ContractError::QuorumNotMet); + } + + let now = env.ledger().timestamp(); + if now < proposal.eta { + return Err(ContractError::TimelockNotElapsed); + } + + // Defense in depth: re-check against the *current* persisted schema + // version at execution time, not just at proposal time. If another + // upgrade already executed and moved the schema version on, this + // proposal is stale and must not activate its WASM against a state + // shape it was never evaluated against. + let current_schema_version = read_schema_version(env)?; + if current_schema_version != proposal.current_schema_version { + return Err(ContractError::StaleProposal); + } + + env.deployer() + .update_current_contract_wasm(proposal.new_wasm_hash.clone()); + + env.storage() + .instance() + .set(&DataKey::SchemaVersion, &proposal.new_schema_version); + + proposal.status = ProposalStatus::Executed; + let proposal_key = DataKey::UpgradeProposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); + env.storage().persistent().extend_ttl( + &proposal_key, + crate::RENT_THRESHOLD, + crate::RENT_EXTEND_TO, + ); + + publish_governance_event(env, "gov_executed_v1", &proposal); + + Ok(proposal) +} + +fn require_approver(config: &GovernanceConfig, address: &Address) -> Result<(), ContractError> { + if is_approver(config, address) { + Ok(()) + } else { + Err(ContractError::NotAnApprover) + } +} + +fn is_approver(config: &GovernanceConfig, address: &Address) -> bool { + config.approvers.contains(address) +} + +fn has_duplicate_address(addresses: &Vec
) -> bool { + for i in 0..addresses.len() { + let candidate = addresses.get(i).unwrap(); + for j in (i + 1)..addresses.len() { + if addresses.get(j).unwrap() == candidate { + return true; + } + } + } + false +} + +#[allow(deprecated)] +fn publish_governance_event(env: &Env, event_name: &str, proposal: &UpgradeProposal) { + env.events().publish( + ( + soroban_sdk::Symbol::new(env, "chainmove_gov_v1"), + soroban_sdk::Symbol::new(env, event_name), + ), + proposal.clone(), + ); +} diff --git a/contracts/chainmove-pool/src/governance_test.rs b/contracts/chainmove-pool/src/governance_test.rs new file mode 100644 index 00000000..ae135a09 --- /dev/null +++ b/contracts/chainmove-pool/src/governance_test.rs @@ -0,0 +1,341 @@ +extern crate std; + +use super::{ChainMovePoolContract, ChainMovePoolContractClient, ContractError, DataKey}; +use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, BytesN, Env, Vec}; + +struct GovFixture { + env: Env, + contract_id: Address, + admin: Address, + approver_a: Address, + approver_b: Address, + approver_c: Address, + non_approver: Address, +} + +const TIMELOCK_SECONDS: u64 = 172_800; // 48h +const QUORUM: u32 = 2; + +fn create_fixture() -> GovFixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ChainMovePoolContract, ()); + let admin = Address::generate(&env); + let approver_a = Address::generate(&env); + let approver_b = Address::generate(&env); + let approver_c = Address::generate(&env); + let non_approver = Address::generate(&env); + + GovFixture { + env, + contract_id, + admin, + approver_a, + approver_b, + approver_c, + non_approver, + } +} + +fn client(fixture: &GovFixture) -> ChainMovePoolContractClient<'_> { + ChainMovePoolContractClient::new(&fixture.env, &fixture.contract_id) +} + +fn approvers(fixture: &GovFixture) -> Vec
{ + let mut v = Vec::new(&fixture.env); + v.push_back(fixture.approver_a.clone()); + v.push_back(fixture.approver_b.clone()); + v.push_back(fixture.approver_c.clone()); + v +} + +fn init_gov(fixture: &GovFixture) { + client(fixture) + .try_init_governance( + &fixture.admin, + &approvers(fixture), + &QUORUM, + &TIMELOCK_SECONDS, + &1u32, + ) + .unwrap() + .unwrap(); +} + +fn wasm_hash(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +#[test] +fn init_governance_rejects_reinitialization() { + let fixture = create_fixture(); + init_gov(&fixture); + + let result = client(&fixture).try_init_governance( + &fixture.admin, + &approvers(&fixture), + &QUORUM, + &TIMELOCK_SECONDS, + &1u32, + ); + + assert_eq!(result, Err(Ok(ContractError::GovernanceAlreadyInitialized))); +} + +#[test] +fn init_governance_rejects_invalid_quorum_configurations() { + let fixture = create_fixture(); + + // Quorum greater than the number of approvers. + let result = client(&fixture).try_init_governance( + &fixture.admin, + &approvers(&fixture), + &10u32, + &TIMELOCK_SECONDS, + &1u32, + ); + assert_eq!(result, Err(Ok(ContractError::InvalidGovernanceConfig))); + + // Zero quorum. + let result = client(&fixture).try_init_governance( + &fixture.admin, + &approvers(&fixture), + &0u32, + &TIMELOCK_SECONDS, + &1u32, + ); + assert_eq!(result, Err(Ok(ContractError::InvalidGovernanceConfig))); + + // Duplicate approver addresses. + let mut dup = Vec::new(&fixture.env); + dup.push_back(fixture.approver_a.clone()); + dup.push_back(fixture.approver_a.clone()); + let result = + client(&fixture).try_init_governance(&fixture.admin, &dup, &1u32, &TIMELOCK_SECONDS, &1u32); + assert_eq!(result, Err(Ok(ContractError::InvalidGovernanceConfig))); +} + +#[test] +fn only_an_approver_can_propose_an_upgrade() { + let fixture = create_fixture(); + init_gov(&fixture); + + let result = client(&fixture).try_propose_upgrade( + &fixture.non_approver, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ); + + assert_eq!(result, Err(Ok(ContractError::NotAnApprover))); +} + +#[test] +fn propose_upgrade_requires_the_next_sequential_schema_version() { + let fixture = create_fixture(); + init_gov(&fixture); + + // Skipping a version is rejected. + let result = client(&fixture).try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &3u32, + &wasm_hash(&fixture.env, 9), + ); + assert_eq!(result, Err(Ok(ContractError::IncompatibleSchemaVersion))); + + // Repeating the current version is rejected. + let result = client(&fixture).try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &1u32, + &wasm_hash(&fixture.env, 9), + ); + assert_eq!(result, Err(Ok(ContractError::IncompatibleSchemaVersion))); + + // Exactly current + 1 succeeds and auto-approves the proposer. + let proposal = client(&fixture) + .try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ) + .unwrap() + .unwrap(); + + assert_eq!(proposal.approvals.len(), 1); + assert_eq!(proposal.eta, proposal.proposed_at + TIMELOCK_SECONDS); +} + +#[test] +fn approve_upgrade_rejects_non_approvers_and_double_approval() { + let fixture = create_fixture(); + init_gov(&fixture); + + let proposal = client(&fixture) + .try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ) + .unwrap() + .unwrap(); + + let non_approver_result = + client(&fixture).try_approve_upgrade(&fixture.non_approver, &proposal.id); + assert_eq!(non_approver_result, Err(Ok(ContractError::NotAnApprover))); + + let double_approval_result = + client(&fixture).try_approve_upgrade(&fixture.approver_a, &proposal.id); + assert_eq!( + double_approval_result, + Err(Ok(ContractError::AlreadyApproved)) + ); + + let approved = client(&fixture) + .try_approve_upgrade(&fixture.approver_b, &proposal.id) + .unwrap() + .unwrap(); + assert_eq!(approved.approvals.len(), 2); +} + +#[test] +fn execute_upgrade_requires_quorum_before_touching_wasm() { + let fixture = create_fixture(); + init_gov(&fixture); + + let proposal = client(&fixture) + .try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ) + .unwrap() + .unwrap(); + + fixture + .env + .ledger() + .set_timestamp(fixture.env.ledger().timestamp() + TIMELOCK_SECONDS + 1); + + // Only one approval (the proposer's) exists; quorum is 2. + let result = client(&fixture).try_execute_upgrade(&fixture.approver_a, &proposal.id); + assert_eq!(result, Err(Ok(ContractError::QuorumNotMet))); +} + +#[test] +fn execute_upgrade_requires_timelock_to_elapse_even_with_quorum() { + let fixture = create_fixture(); + init_gov(&fixture); + + let proposal = client(&fixture) + .try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ) + .unwrap() + .unwrap(); + + client(&fixture) + .try_approve_upgrade(&fixture.approver_b, &proposal.id) + .unwrap() + .unwrap(); + + // Quorum (2) is met, but the timelock has not elapsed. + let result = client(&fixture).try_execute_upgrade(&fixture.approver_a, &proposal.id); + assert_eq!(result, Err(Ok(ContractError::TimelockNotElapsed))); +} + +#[test] +fn execute_upgrade_rejects_a_stale_proposal_when_schema_version_moved_on() { + let fixture = create_fixture(); + init_gov(&fixture); + + let proposal = client(&fixture) + .try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ) + .unwrap() + .unwrap(); + + client(&fixture) + .try_approve_upgrade(&fixture.approver_b, &proposal.id) + .unwrap() + .unwrap(); + + fixture + .env + .ledger() + .set_timestamp(fixture.env.ledger().timestamp() + TIMELOCK_SECONDS + 1); + + // Simulate a different upgrade having already executed and advanced the + // persisted schema version out from under this proposal. + fixture.env.as_contract(&fixture.contract_id, || { + fixture + .env + .storage() + .instance() + .set(&DataKey::SchemaVersion, &5u32); + }); + + let result = client(&fixture).try_execute_upgrade(&fixture.approver_a, &proposal.id); + assert_eq!(result, Err(Ok(ContractError::StaleProposal))); +} + +#[test] +fn cancel_upgrade_allows_proposer_or_approver_but_not_outsiders() { + let fixture = create_fixture(); + init_gov(&fixture); + + let proposal = client(&fixture) + .try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ) + .unwrap() + .unwrap(); + + let outsider_result = client(&fixture).try_cancel_upgrade(&fixture.non_approver, &proposal.id); + assert_eq!(outsider_result, Err(Ok(ContractError::NotAnApprover))); + + let canceled = client(&fixture) + .try_cancel_upgrade(&fixture.approver_b, &proposal.id) + .unwrap() + .unwrap(); + assert_eq!(canceled.status, super::ProposalStatus::Canceled); + + // A canceled proposal cannot be approved, executed, or canceled again + // (no replay of a dead proposal). + let reapprove = client(&fixture).try_approve_upgrade(&fixture.approver_c, &proposal.id); + assert_eq!(reapprove, Err(Ok(ContractError::ProposalNotPending))); + + let reexecute = client(&fixture).try_execute_upgrade(&fixture.approver_a, &proposal.id); + assert_eq!(reexecute, Err(Ok(ContractError::ProposalNotPending))); + + let recancel = client(&fixture).try_cancel_upgrade(&fixture.approver_a, &proposal.id); + assert_eq!(recancel, Err(Ok(ContractError::ProposalNotPending))); +} + +#[test] +fn governance_actions_before_bootstrap_fail_closed() { + let fixture = create_fixture(); + + let result = client(&fixture).try_propose_upgrade( + &fixture.approver_a, + &wasm_hash(&fixture.env, 1), + &2u32, + &wasm_hash(&fixture.env, 9), + ); + assert_eq!(result, Err(Ok(ContractError::GovernanceNotInitialized))); +} diff --git a/contracts/chainmove-pool/src/lib.rs b/contracts/chainmove-pool/src/lib.rs index fa9e705d..c2c0877b 100644 --- a/contracts/chainmove-pool/src/lib.rs +++ b/contracts/chainmove-pool/src/lib.rs @@ -1,9 +1,13 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Symbol, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, String, + Symbol, Vec, }; +mod governance; +pub use governance::{GovernanceConfig, ProposalStatus, UpgradeProposal}; + #[contract] pub struct ChainMovePoolContract; @@ -24,6 +28,17 @@ pub enum ContractError { Overpayment = 11, RepayerMismatch = 12, NothingToRefund = 13, + GovernanceAlreadyInitialized = 14, + GovernanceNotInitialized = 15, + InvalidGovernanceConfig = 16, + NotAnApprover = 17, + AlreadyApproved = 18, + ProposalNotFound = 19, + ProposalNotPending = 20, + QuorumNotMet = 21, + TimelockNotElapsed = 22, + IncompatibleSchemaVersion = 23, + StaleProposal = 24, } #[contracttype] @@ -92,6 +107,10 @@ enum DataKey { InvestorPosition(u64, Address), Reference(String), LegacyPool(u64), // Legacy key format for migration testing + GovernanceConfig, + SchemaVersion, + UpgradeProposal(u64), + NextProposalId, } const DAY_IN_LEDGERS: u32 = 17280; @@ -565,6 +584,106 @@ impl ChainMovePoolContract { .ok_or(ContractError::ArithmeticOverflow)?; Ok((numerator / pool.target_amount) as u64) } + + // --- Governed WASM upgrades ------------------------------------------------- + // + // No single signer can move the contract's active WASM: an upgrade must be + // proposed by an approver, collect approvals from at least `quorum` distinct + // approvers, and wait until a fixed timelock elapses before it can execute. + // Execution also re-validates the persisted schema version, so an + // incompatible migration can never activate new code against old state. + + /// One-time bootstrap of the governance parameters. Fails if governance + /// has already been initialized. + pub fn init_governance( + env: Env, + admin: Address, + approvers: Vec
, + quorum: u32, + timelock_seconds: u64, + initial_schema_version: u32, + ) -> Result { + env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + governance::init_governance( + &env, + admin, + approvers, + quorum, + timelock_seconds, + initial_schema_version, + ) + } + + /// Proposes a WASM upgrade to `new_wasm_hash`, declaring the next schema + /// version and a commitment to the accompanying migration plan. Only an + /// approver may propose. The new schema version must be exactly one + /// greater than the currently active schema version. + pub fn propose_upgrade( + env: Env, + proposer: Address, + new_wasm_hash: BytesN<32>, + new_schema_version: u32, + migration_plan_hash: BytesN<32>, + ) -> Result { + env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + governance::propose_upgrade( + &env, + proposer, + new_wasm_hash, + new_schema_version, + migration_plan_hash, + ) + } + + /// Records an approver's approval for a pending proposal. Each approver + /// may approve a given proposal at most once. + pub fn approve_upgrade( + env: Env, + approver: Address, + proposal_id: u64, + ) -> Result { + env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + governance::approve_upgrade(&env, approver, proposal_id) + } + + /// Cancels a pending proposal. Callable by the original proposer or any + /// approver; irreversible once canceled. + pub fn cancel_upgrade( + env: Env, + canceler: Address, + proposal_id: u64, + ) -> Result { + env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + governance::cancel_upgrade(&env, canceler, proposal_id) + } + + /// Executes a pending proposal once quorum and the timelock have both + /// been satisfied and the persisted schema version still matches what + /// the proposal was evaluated against. Swaps the contract's WASM and + /// advances the persisted schema version. + pub fn execute_upgrade( + env: Env, + executor: Address, + proposal_id: u64, + ) -> Result { + env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + governance::execute_upgrade(&env, executor, proposal_id) + } + + pub fn read_governance_config(env: Env) -> Result { + governance::read_governance_config(&env) + } + + pub fn read_schema_version(env: Env) -> Result { + governance::read_schema_version(&env) + } + + pub fn read_upgrade_proposal( + env: Env, + proposal_id: u64, + ) -> Result { + governance::read_upgrade_proposal(&env, proposal_id) + } } fn read_idempotent_position( @@ -740,3 +859,6 @@ fn checked_sub_u64(left: u64, right: u64) -> Result { #[cfg(test)] mod test; + +#[cfg(test)] +mod governance_test;