diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs
index 8145f0e..1355ee8 100644
--- a/prediction_market/src/lib.rs
+++ b/prediction_market/src/lib.rs
@@ -1,8 +1,8 @@
#![no_std]
use soroban_sdk::{
- contract, contracterror, contractimpl, contracttype, token, vec, Address, BytesN, Env, IntoVal,
- String, Symbol, Val, Vec,
+ contract, contracterror, contractimpl, contracttype, token, vec, Address, BytesN, Env, Executable,
+ IntoVal, String, Symbol, Val, Vec,
};
// ── Event schema (issue #52) ────────────────────────────────────────────────
@@ -47,6 +47,8 @@ const LOSE_TOKENS: i128 = 2_0000000;
// accumulator to an arbitrary address in one call.
const WITHDRAW_DELAY_SECS: u64 = 86_400; // 24h timelock between request and payout
const MAX_WITHDRAWAL_BPS: i128 = 2_000; // per-request cap: 20% of accumulated fees
+const CONFIG_DELAY_SECS: u64 = 86_400; // issue #51: dispute window before Config is live
+const MAX_GOVERNORS: u32 = 10;
// TTL: ~1yr threshold, ~2yr extend (mainnet: ~1 ledger/5s)
const TTL_BUMP: u32 = 3_153_600;
@@ -96,6 +98,14 @@ pub enum MarketError {
WithdrawalTooSoon = 25,
ContractPaused = 26,
InvalidDuration = 27, // issue #10: duration below the minimum
+ InvalidDependency = 28, // issue #51: address is not the expected executable kind
+ WasmHashMismatch = 29, // issue #51: live WASM hash != pinned / pending hash
+ ConfigChangeExists = 30,
+ NoConfigChange = 31,
+ ConfigChangeTooSoon = 32,
+ InsufficientApprovals = 33,
+ AlreadyApproved = 34,
+ InvalidThreshold = 35,
/// A dependency (referral_registry or leaderboard) reported an
/// interface_version this contract wasn't built against (issue #84).
/// Note: a matching version number alone does not prove the callee's
@@ -128,6 +138,12 @@ pub enum DataKey {
Payout(u64, Address), // i128 — exact payout computed at resolve time
// ── Timelocked withdrawal requests (issue #12) ───────────────────────
PendingWithdrawal(Address), // caller -> WithdrawalRequest
+ // ── Dependency governance (issue #51) ────────────────────────────────
+ Governor(Address),
+ GovernorCount,
+ GovernorThreshold,
+ PendingConfig,
+ PinnedHashes,
// ── Emergency circuit-breaker (issue #83) ─────────────────────────────
Paused,
}
@@ -142,6 +158,26 @@ pub struct Config {
pub xlm_sac: Address,
}
+/// WASM hashes (or the SAC sentinel) pinned for each Config role.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PinnedHashes {
+ pub token: BytesN<32>,
+ pub referral: BytesN<32>,
+ pub leaderboard: BytesN<32>,
+ pub xlm_sac: BytesN<32>,
+}
+
+/// Timelocked, multi-sig Config mutation. Inactive until execute_set_config.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PendingConfigChange {
+ pub cfg: Config,
+ pub hashes: PinnedHashes,
+ pub requested_at: u64,
+ pub approvers: Vec
,
+}
+
// ── BetEntry: Bet + Gross + BetCount in one slot ──────────────────────────
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -236,6 +272,28 @@ impl PredictionMarketContract {
env.storage()
.instance()
.set(&DataKey::AccumulatedFees, &0_i128);
+
+ // Bootstrap governance: the initializer is the first governor with
+ // a 1-of-1 threshold. Production deploys should add more governors
+ // and raise the threshold before relying on set_config.
+ env.storage()
+ .persistent()
+ .set(&DataKey::Governor(admin.clone()), &true);
+ env.storage().instance().set(&DataKey::GovernorCount, &1_u32);
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernorThreshold, &1_u32);
+ if let Ok(hashes) = Self::fingerprint_config(
+ &env,
+ &token_contract,
+ &referral_contract,
+ &leaderboard_contract,
+ &xlm_sac,
+ ) {
+ env.storage()
+ .instance()
+ .set(&DataKey::PinnedHashes, &hashes);
+ }
env.events().publish(
(Symbol::new(&env, "initialized"), admin),
(token_contract, referral_contract, leaderboard_contract, xlm_sac),
@@ -256,16 +314,41 @@ impl PredictionMarketContract {
Ok(())
}
- /// Update the packed Config (token / referral / leaderboard / xlm_sac). Admin only.
- /// Used to correct an address set at initialize time.
+ /// Propose a Config change. Does **not** take effect immediately.
+ ///
+ /// Issue #51: live WASM hashes are read on-chain (not caller-supplied),
+ /// the proposal is emitted for monitors, and it only becomes active after
+ /// `CONFIG_DELAY_SECS` **and** `GovernorThreshold` approvals via
+ /// `execute_set_config`. Any governor can `cancel_set_config` in between.
pub fn set_config(
env: Env,
- admin: Address,
+ caller: Address,
token_contract: Address,
referral_contract: Address,
leaderboard_contract: Address,
xlm_sac: Address,
) -> Result<(), MarketError> {
+ caller.require_auth();
+ Self::require_governor(&env, &caller)?;
+ if env.storage().instance().has(&DataKey::PendingConfig) {
+ return Err(MarketError::ConfigChangeExists);
+ }
+
+ let hashes = Self::fingerprint_config(
+ &env,
+ &token_contract,
+ &referral_contract,
+ &leaderboard_contract,
+ &xlm_sac,
+ )?;
+ let mut approvers: Vec = Vec::new(&env);
+ approvers.push_back(caller.clone());
+ let pending = PendingConfigChange {
+ cfg: Config {
+ token: token_contract,
+ referral: referral_contract,
+ leaderboard: leaderboard_contract,
+ xlm_sac,
Self::require_admin(&env, &admin)?;
admin.require_auth();
env.storage().instance().set(
@@ -276,6 +359,87 @@ impl PredictionMarketContract {
leaderboard: leaderboard_contract.clone(),
xlm_sac: xlm_sac.clone(),
},
+ hashes,
+ requested_at: env.ledger().timestamp(),
+ approvers,
+ };
+ env.storage()
+ .instance()
+ .set(&DataKey::PendingConfig, &pending);
+ env.events().publish(
+ (Symbol::new(&env, "cfg_req"), caller),
+ pending,
+ );
+ Ok(())
+ }
+
+ /// A governor attests a pending Config change during the dispute window.
+ pub fn approve_set_config(env: Env, caller: Address) -> Result {
+ caller.require_auth();
+ Self::require_governor(&env, &caller)?;
+ let mut pending: PendingConfigChange = env
+ .storage()
+ .instance()
+ .get(&DataKey::PendingConfig)
+ .ok_or(MarketError::NoConfigChange)?;
+ if Self::approver_index(&pending.approvers, &caller).is_some() {
+ return Err(MarketError::AlreadyApproved);
+ }
+ pending.approvers.push_back(caller.clone());
+ let count = pending.approvers.len();
+ env.storage()
+ .instance()
+ .set(&DataKey::PendingConfig, &pending);
+ env.events().publish(
+ (Symbol::new(&env, "cfg_ok"), caller),
+ count,
+ );
+ Ok(count)
+ }
+
+ /// Activate a matured, sufficiently-approved Config change. Re-reads live
+ /// executables so a dependency cannot swap WASM during the delay.
+ pub fn execute_set_config(env: Env, caller: Address) -> Result<(), MarketError> {
+ caller.require_auth();
+ Self::require_governor(&env, &caller)?;
+ let pending: PendingConfigChange = env
+ .storage()
+ .instance()
+ .get(&DataKey::PendingConfig)
+ .ok_or(MarketError::NoConfigChange)?;
+
+ let now = env.ledger().timestamp();
+ if now < pending.requested_at || now - pending.requested_at < CONFIG_DELAY_SECS {
+ return Err(MarketError::ConfigChangeTooSoon);
+ }
+ let threshold: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernorThreshold)
+ .unwrap_or(1);
+ if pending.approvers.len() < threshold {
+ return Err(MarketError::InsufficientApprovals);
+ }
+
+ let live = Self::fingerprint_config(
+ &env,
+ &pending.cfg.token,
+ &pending.cfg.referral,
+ &pending.cfg.leaderboard,
+ &pending.cfg.xlm_sac,
+ )?;
+ if live != pending.hashes {
+ return Err(MarketError::WasmHashMismatch);
+ }
+
+ env.storage().instance().set(&DataKey::Cfg, &pending.cfg);
+ env.storage()
+ .instance()
+ .set(&DataKey::PinnedHashes, &pending.hashes);
+ env.storage().instance().remove(&DataKey::PendingConfig);
+ env.events().publish(
+ (Symbol::new(&env, "cfg_act"), caller),
+ pending.cfg,
);
env.events().publish(
(Symbol::new(&env, "config_changed"), admin),
@@ -284,11 +448,123 @@ impl PredictionMarketContract {
Ok(())
}
+ /// Cancel a pending Config change during the dispute window.
+ pub fn cancel_set_config(env: Env, caller: Address) -> Result<(), MarketError> {
+ caller.require_auth();
+ Self::require_governor(&env, &caller)?;
+ if !env.storage().instance().has(&DataKey::PendingConfig) {
+ return Err(MarketError::NoConfigChange);
+ }
+ env.storage().instance().remove(&DataKey::PendingConfig);
+ env.events().publish(
+ (Symbol::new(&env, "cfg_can"), caller),
+ 1_u32,
+ );
+ Ok(())
+ }
+
+ pub fn add_governor(env: Env, admin: Address, governor: Address) -> Result<(), MarketError> {
+ Self::require_admin(&env, &admin)?;
+ admin.require_auth();
+ let key = DataKey::Governor(governor.clone());
+ if env.storage().persistent().get(&key).unwrap_or(false) {
+ return Ok(());
+ }
+ let count: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernorCount)
+ .unwrap_or(0);
+ if count >= MAX_GOVERNORS {
+ return Err(MarketError::RateLimitExceeded);
+ }
+ env.storage().persistent().set(&key, &true);
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernorCount, &(count + 1));
+ Ok(())
+ }
+
+ pub fn remove_governor(env: Env, admin: Address, governor: Address) -> Result<(), MarketError> {
+ Self::require_admin(&env, &admin)?;
+ admin.require_auth();
+ let count: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernorCount)
+ .unwrap_or(0);
+ let threshold: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernorThreshold)
+ .unwrap_or(1);
+ if count <= threshold {
+ return Err(MarketError::InvalidThreshold);
+ }
+ let key = DataKey::Governor(governor);
+ if !env.storage().persistent().get(&key).unwrap_or(false) {
+ return Err(MarketError::NotAuthorized);
+ }
+ env.storage().persistent().remove(&key);
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernorCount, &(count - 1));
+ Ok(())
+ }
+
+ pub fn set_governor_threshold(
+ env: Env,
+ admin: Address,
+ threshold: u32,
+ ) -> Result<(), MarketError> {
+ Self::require_admin(&env, &admin)?;
+ admin.require_auth();
+ let count: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernorCount)
+ .unwrap_or(0);
+ if threshold == 0 || threshold > count {
+ return Err(MarketError::InvalidThreshold);
+ }
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernorThreshold, &threshold);
+ Ok(())
+ }
+
/// Read the current Config (for verification/admin tooling).
pub fn get_config(env: Env) -> Config {
env.storage().instance().get(&DataKey::Cfg).unwrap()
}
+ pub fn get_pending_config(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::PendingConfig)
+ }
+
+ pub fn get_pinned_hashes(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::PinnedHashes)
+ }
+
+ pub fn is_governor(env: Env, account: Address) -> bool {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Governor(account))
+ .unwrap_or(false)
+ }
+
+ pub fn get_governor_threshold(env: Env) -> u32 {
+ env.storage()
+ .instance()
+ .get(&DataKey::GovernorThreshold)
+ .unwrap_or(1)
+ }
+
+ pub fn get_governor_count(env: Env) -> u32 {
+ env.storage()
+ .instance()
+ .get(&DataKey::GovernorCount)
+ .unwrap_or(0)
/// The cross-contract ABI version this deployment implements (issue #84).
pub fn interface_version(_env: Env) -> u32 {
INTERFACE_VERSION
@@ -1170,6 +1446,67 @@ impl PredictionMarketContract {
// ── Internal Helpers ──────────────────────────────────────────────────
+ fn sac_sentinel(env: &Env) -> BytesN<32> {
+ BytesN::from_array(env, &[0u8; 32])
+ }
+
+ /// Live executable fingerprint for a dependency.
+ /// token / referral / leaderboard must be WASM; xlm_sac must be the SAC.
+ fn fingerprint(env: &Env, addr: &Address, expect_sac: bool) -> Result, MarketError> {
+ match addr.executable() {
+ Some(Executable::Wasm(hash)) => {
+ if expect_sac {
+ return Err(MarketError::InvalidDependency);
+ }
+ Ok(hash)
+ }
+ Some(Executable::StellarAsset) => {
+ if !expect_sac {
+ return Err(MarketError::InvalidDependency);
+ }
+ Ok(Self::sac_sentinel(env))
+ }
+ Some(Executable::Account) | None => Err(MarketError::InvalidDependency),
+ }
+ }
+
+ fn fingerprint_config(
+ env: &Env,
+ token: &Address,
+ referral: &Address,
+ leaderboard: &Address,
+ xlm_sac: &Address,
+ ) -> Result {
+ Ok(PinnedHashes {
+ token: Self::fingerprint(env, token, false)?,
+ referral: Self::fingerprint(env, referral, false)?,
+ leaderboard: Self::fingerprint(env, leaderboard, false)?,
+ xlm_sac: Self::fingerprint(env, xlm_sac, true)?,
+ })
+ }
+
+ fn approver_index(approvers: &Vec, who: &Address) -> Option {
+ let n = approvers.len();
+ for i in 0..n {
+ if approvers.get(i).unwrap() == *who {
+ return Some(i);
+ }
+ }
+ None
+ }
+
+ fn require_governor(env: &Env, caller: &Address) -> Result<(), MarketError> {
+ if env
+ .storage()
+ .persistent()
+ .get(&DataKey::Governor(caller.clone()))
+ .unwrap_or(false)
+ {
+ return Ok(());
+ }
+ Err(MarketError::NotAuthorized)
+ }
+
#[inline]
fn load_market(env: &Env, market_id: u64) -> Result {
env.storage()
diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs
index 08390af..346fef2 100644
--- a/prediction_market/src/tests.rs
+++ b/prediction_market/src/tests.rs
@@ -4,6 +4,7 @@ use soroban_sdk::{
contract, contractimpl,
testutils::{storage::Persistent as _, Address as _, Ledger, LedgerInfo},
token::{Client as TokenClient, StellarAssetClient},
+ BytesN, Env, String,
Env, String, Symbol, TryFromVal, Val,
};
@@ -1826,6 +1827,180 @@ fn test_cancel_withdrawal_request_still_works_while_paused() {
assert!(t.client.get_pending_withdrawal(&recipient).is_none());
}
+// ═══════════════════════════════════════════════════════════════════════════
+// SECURITY REGRESSION — issue #51 (set_config pinning / governance)
+// ═══════════════════════════════════════════════════════════════════════════
+
+fn second_leaderboard(t: &TestSetup) -> Address {
+ let id = t.env.register(LeaderboardContract, ());
+ let client = leaderboard::LeaderboardContractClient::new(&t.env, &id);
+ client.initialize(&t.admin, &t.client.address, &t.referral_client.address);
+ id
+}
+
+#[test]
+fn test_set_config_does_not_apply_immediately() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ let new_lb = second_leaderboard(&t);
+
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &new_lb,
+ &cfg.xlm_sac,
+ );
+
+ // Live config is unchanged until execute_set_config after the delay.
+ assert_eq!(t.client.get_config().leaderboard, cfg.leaderboard);
+ let pending = t.client.get_pending_config().expect("pending change");
+ assert_eq!(pending.cfg.leaderboard, new_lb);
+ assert_eq!(pending.approvers.len(), 1);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #28)")]
+fn test_set_config_rejects_arbitrary_address() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ let attacker = Address::generate(&t.env);
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &attacker,
+ &cfg.leaderboard,
+ &cfg.xlm_sac,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #28)")]
+fn test_set_config_rejects_wasm_as_xlm_sac() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ // A WASM/native contract must not be installable as the XLM SAC.
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &cfg.leaderboard,
+ &cfg.token,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #32)")]
+fn test_set_config_execute_before_delay() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ let new_lb = second_leaderboard(&t);
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &new_lb,
+ &cfg.xlm_sac,
+ );
+ t.client.execute_set_config(&t.admin);
+}
+
+#[test]
+fn test_set_config_execute_after_delay_and_pin() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ let new_lb = second_leaderboard(&t);
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &new_lb,
+ &cfg.xlm_sac,
+ );
+ advance_time(&t.env, CONFIG_DELAY_SECS);
+ t.client.execute_set_config(&t.admin);
+
+ assert_eq!(t.client.get_config().leaderboard, new_lb);
+ assert!(t.client.get_pending_config().is_none());
+ let pins = t.client.get_pinned_hashes().expect("pins");
+ assert_eq!(pins.xlm_sac, BytesN::from_array(&t.env, &[0u8; 32]));
+}
+
+#[test]
+fn test_cancel_set_config_during_dispute_window() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ let new_lb = second_leaderboard(&t);
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &new_lb,
+ &cfg.xlm_sac,
+ );
+ t.client.cancel_set_config(&t.admin);
+ assert!(t.client.get_pending_config().is_none());
+ assert_eq!(t.client.get_config().leaderboard, cfg.leaderboard);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #33)")]
+fn test_set_config_multisig_requires_threshold() {
+ let t = setup();
+ let g2 = Address::generate(&t.env);
+ t.client.add_governor(&t.admin, &g2);
+ t.client.set_governor_threshold(&t.admin, &2_u32);
+
+ let cfg = t.client.get_config();
+ let new_lb = second_leaderboard(&t);
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &new_lb,
+ &cfg.xlm_sac,
+ );
+ advance_time(&t.env, CONFIG_DELAY_SECS);
+ // Only the proposer approved (1 of 2).
+ t.client.execute_set_config(&t.admin);
+}
+
+#[test]
+fn test_set_config_multisig_execute_with_second_approval() {
+ let t = setup();
+ let g2 = Address::generate(&t.env);
+ t.client.add_governor(&t.admin, &g2);
+ t.client.set_governor_threshold(&t.admin, &2_u32);
+
+ let cfg = t.client.get_config();
+ let new_lb = second_leaderboard(&t);
+ t.client.set_config(
+ &t.admin,
+ &cfg.token,
+ &cfg.referral,
+ &new_lb,
+ &cfg.xlm_sac,
+ );
+ t.client.approve_set_config(&g2);
+ advance_time(&t.env, CONFIG_DELAY_SECS);
+ t.client.execute_set_config(&g2);
+
+ assert_eq!(t.client.get_config().leaderboard, new_lb);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #18)")]
+fn test_set_config_non_governor_rejected() {
+ let t = setup();
+ let cfg = t.client.get_config();
+ let stranger = Address::generate(&t.env);
+ t.client.set_config(
+ &stranger,
+ &cfg.token,
+ &cfg.referral,
+ &cfg.leaderboard,
+ &cfg.xlm_sac,
+ );
fn last_event_name(env: &Env) -> Symbol {
let events = env.events().all();
let last = events.get(events.len() - 1).unwrap();