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;