From d97d53df4fa71edc9c45bb21dfd1226442dbb756 Mon Sep 17 00:00:00 2001 From: Jessica Date: Sat, 17 Oct 2026 15:06:44 +0000 Subject: [PATCH] Fix CRITICAL admin bootstrap & MEDIUM delegation cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL (markets): Admin bootstrap allowed any caller when admin was unset. Added initialize() entrypoint that atomically stores admin and initialized flag. is_admin_internal() now returns false when no admin is stored (was true). Added pause_markets, unpause_markets, transfer_ownership, add_admin entrypoints all gated by assert_is_admin(). Added get_admin and is_paused read-only entrypoints. Fixed proptest error variant mismatch (AdminCooldownActive vs AdminActionTimelocked). MEDIUM (predictify-hybrid): Created delegation.rs with DelegationManager implementing set_delegate/unset_delegate. Enforces 10% cap on delegated voting power per address (MAX_DELEGATION_BPS=1000 of BPS_DENOMINATOR=10000). Cap checked at delegation time; excess rejected with DelegationCapExceeded. Added DelegationCapExceeded=676 error variant and updated err_stability snapshot (120→121 variants). Tests: 14 auth boundary tests for markets (initialize, pause/unpause, transfer_ownership, add_admin, admin-unset rejection, read-only). 13 unit tests in delegation.rs (basic delegation, cap boundary, multiple delegators, re-delegation, self-delegation rejection, zero stake, unset, cap calc). --- Cargo.toml | 5 - contracts/markets/Cargo.toml | 4 + contracts/markets/src/lib.rs | 180 ++++++- contracts/markets/tests/auth_boundary.rs | 250 ++++++++++ contracts/markets/tests/proptest.rs | 2 +- contracts/predictify-hybrid/src/delegation.rs | 460 ++++++++++++++++++ contracts/predictify-hybrid/src/err.rs | 3 + contracts/predictify-hybrid/src/lib.rs | 1 + .../predictify-hybrid/tests/err_stability.rs | 3 +- 9 files changed, 900 insertions(+), 8 deletions(-) create mode 100644 contracts/markets/tests/auth_boundary.rs create mode 100644 contracts/predictify-hybrid/src/delegation.rs diff --git a/Cargo.toml b/Cargo.toml index 2f575599..f8ddf34f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,8 +28,3 @@ inherits = "release" debug-assertions = true -[workspace.members] -"contracts/auction" - -[workspace.members] -"contracts/analytics" diff --git a/contracts/markets/Cargo.toml b/contracts/markets/Cargo.toml index 4402d269..3bd63505 100644 --- a/contracts/markets/Cargo.toml +++ b/contracts/markets/Cargo.toml @@ -14,3 +14,7 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } proptest = "1.2.0" + +[[test]] +name = "auth_boundary" +path = "tests/auth_boundary.rs" diff --git a/contracts/markets/src/lib.rs b/contracts/markets/src/lib.rs index f9c892e9..836ed4d5 100644 --- a/contracts/markets/src/lib.rs +++ b/contracts/markets/src/lib.rs @@ -9,6 +9,7 @@ //! //! | Function | Required Role | //! |------------------------|----------------------------| +//! | `initialize` | Admin (one-time) | //! | `create_market` | Creator (any address) | //! | `place_bet` | Bettor (any address) | //! | `resolve_market` | Market creator | @@ -21,11 +22,14 @@ //! | `pause_markets` | Admin | //! | `unpause_markets` | Admin | //! | `transfer_ownership` | Admin | +//! | `add_admin` | Admin | +//! | `get_admin` | Anyone (read-only) | +//! | `is_paused` | Anyone (read-only) | //! | `version` | Anyone (read-only) | pub mod errors; -use soroban_sdk::{contract, contractimpl, contracttype, panic_with_error, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; pub use errors::ContractError; @@ -46,6 +50,8 @@ pub enum DataKey { Paused, /// The admin / owner address. Admin, + /// Whether the contract has been initialized. + Initialized, /// Liquidity provided by a user to a specific market. Liquidity(u32, Address), } @@ -122,4 +128,176 @@ impl MarketsContract { market } + // ----------------------------------------------------------------------- + // Initialization + // ----------------------------------------------------------------------- + + /// @notice Initializes the contract by setting the initial admin. + /// @dev Stores `DataKey::Admin` and `DataKey::Initialized` atomically. + /// Must be called exactly once after deployment before any admin + /// entrypoint can be used. + /// @param env The contract environment. + /// @param admin The initial admin/owner address. + /// @return `Ok(())` on success, or a [`ContractError`] variant. + pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { + // Require the caller to authenticate before any state mutation. + admin.require_auth(); + + // Prevent re-initialization. + if Self::is_initialized(&env) { + return Err(ContractError::AdminOperationNotPermitted); + } + + env.storage().instance().set(&DataKey::Initialized, &true); + env.storage().instance().set(&DataKey::Admin, &admin); + // Markets start unpaused. + env.storage().instance().set(&DataKey::Paused, &false); + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Admin-gated entrypoints + // ----------------------------------------------------------------------- + + /// @notice Pauses all markets globally. + /// @dev Requires the caller to be the registered admin. + /// @param env The contract environment. + /// @param caller The admin address (must pass auth and admin check). + /// @return `Ok(())` on success, or a [`ContractError`] variant. + pub fn pause_markets(env: Env, caller: Address) -> Result<(), ContractError> { + caller.require_auth(); + Self::assert_is_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Paused, &true); + Ok(()) + } + + /// @notice Unpauses all markets globally. + /// @dev Requires the caller to be the registered admin. + /// @param env The contract environment. + /// @param caller The admin address (must pass auth and admin check). + /// @return `Ok(())` on success, or a [`ContractError`] variant. + pub fn unpause_markets(env: Env, caller: Address) -> Result<(), ContractError> { + caller.require_auth(); + Self::assert_is_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Paused, &false); + Ok(()) + } + + /// @notice Transfers contract ownership to a new admin address. + /// @dev Requires the caller to be the registered admin. The new admin + /// must differ from the current admin. + /// @param env The contract environment. + /// @param current_admin The current admin (must pass auth and admin check). + /// @param new_admin The new admin address to set. + /// @return `Ok(())` on success, or a [`ContractError`] variant. + pub fn transfer_ownership( + env: Env, + current_admin: Address, + new_admin: Address, + ) -> Result<(), ContractError> { + current_admin.require_auth(); + Self::assert_is_admin(&env, ¤t_admin)?; + + if new_admin == current_admin { + return Err(ContractError::AdminAddressInvalid); + } + + env.storage().instance().set(&DataKey::Admin, &new_admin); + Ok(()) + } + + /// @notice Adds a second admin address (co-admin). + /// @dev Requires the caller to be the registered admin. Currently only + /// stores a single admin; this entrypoint replaces the admin with + /// the new address and is kept for forward compatibility with a + /// multi-admin model. + /// @param env The contract environment. + /// @param caller The current admin (must pass auth and admin check). + /// @param new_admin The admin address to add. + /// @return `Ok(())` on success, or a [`ContractError`] variant. + pub fn add_admin( + env: Env, + caller: Address, + new_admin: Address, + ) -> Result<(), ContractError> { + caller.require_auth(); + Self::assert_is_admin(&env, &caller)?; + + if new_admin == caller { + return Err(ContractError::AdminAddressInvalid); + } + + // Store the new admin alongside the existing admin. + // For now we support a single admin; this entrypoint replaces it. + env.storage().instance().set(&DataKey::Admin, &new_admin); + Ok(()) + } + + // ----------------------------------------------------------------------- + // Read-only admin queries + // ----------------------------------------------------------------------- + + /// @notice Returns the current admin address. + /// @dev Read-only — does not require auth. Returns `None` if not set. + /// @param env The contract environment. + /// @return `Some(Address)` if an admin is set, `None` otherwise. + pub fn get_admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + /// @notice Returns whether the markets subsystem is globally paused. + /// @dev Read-only — does not require auth. + /// @param env The contract environment. + /// @return `true` if paused, `false` otherwise. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /// @notice Returns `true` if the contract has been initialized. + fn is_initialized(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Initialized) + .unwrap_or(false) + } + + /// @notice Returns the stored admin address, or `None` if unset. + fn get_admin_internal(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + /// @notice Checks whether `caller` is the registered admin. + /// @dev Returns `false` when no admin is stored (does **not** return + /// `true` for an unset admin — this closes the bootstrap hole). + /// @param env The contract environment. + /// @param caller The address to check. + /// @return `true` if the caller matches the stored admin, `false` otherwise. + fn is_admin_internal(env: &Env, caller: &Address) -> bool { + match Self::get_admin_internal(env) { + Some(admin) => admin == *caller, + None => false, + } + } + + /// @notice Asserts that `caller` is the registered admin. + /// @dev Returns `Err(ContractError::Unauthorized)` when the admin is + /// unset or the caller does not match. + /// @param env The contract environment. + /// @param caller The address to verify. + /// @return `Ok(())` if the caller is the admin, or a [`ContractError`]. + fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { + if !Self::is_admin_internal(env, caller) { + return Err(ContractError::Unauthorized); + } + Ok(()) + } + } diff --git a/contracts/markets/tests/auth_boundary.rs b/contracts/markets/tests/auth_boundary.rs new file mode 100644 index 00000000..1d69c1cd --- /dev/null +++ b/contracts/markets/tests/auth_boundary.rs @@ -0,0 +1,250 @@ +//! Auth boundary tests for the MarketsContract admin entrypoints. +//! +//! Verifies: +//! * `initialize` requires caller auth and sets admin atomically. +//! * `initialize` cannot be called twice. +//! * `pause_markets` / `unpause_markets` require admin auth. +//! * `transfer_ownership` requires admin auth and rejects self-transfer. +//! * `add_admin` requires admin auth. +//! * Non-admin callers are rejected on all admin entrypoints. +//! * Read-only entrypoints (`get_admin`, `is_paused`, `version`) require no auth. +//! * Admin check returns false (not true) when admin is unset. + +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env}; +use markets::{ContractError, MarketsContract, MarketsContractClient}; + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +/// Register, initialize, and drain the init auth snapshot. +fn register_and_initialize<'a>(env: &Env, admin: &Address) -> MarketsContractClient<'a> { + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(env, &contract_id); + env.mock_all_auths(); + client.initialize(admin); + let _ = env.auths(); + client +} + +// --------------------------------------------------------------------------- +// initialize +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_sets_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + let stored_admin = client.get_admin(); + assert!(stored_admin.is_some()); + assert_eq!(stored_admin.unwrap(), admin); +} + +#[test] +fn test_initialize_cannot_reinitialize() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_initialize(&admin); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::AdminOperationNotPermitted + ); + + let new_admin = Address::generate(&env); + let result = client.try_initialize(&new_admin); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::AdminOperationNotPermitted + ); +} + +#[test] +fn test_initialize_requires_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + // Do NOT mock all auths — we want to verify auth is required. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + // Without auth, this should fail. + let result = client.try_initialize(&admin); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// pause_markets / unpause_markets +// --------------------------------------------------------------------------- + +#[test] +fn test_pause_markets_requires_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + // Attacker tries to pause — should be rejected. + let result = client.try_pause_markets(&attacker); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Markets should not be paused. + assert_eq!(client.is_paused(), false); +} + +#[test] +fn test_pause_unpause_succeeds_with_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.pause_markets(&admin); + assert_eq!(client.is_paused(), true); + + client.unpause_markets(&admin); + assert_eq!(client.is_paused(), false); +} + +#[test] +fn test_unpause_markets_requires_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.pause_markets(&admin); + assert_eq!(client.is_paused(), true); + + // Attacker tries to unpause — should be rejected. + let result = client.try_unpause_markets(&attacker); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); +} + +// --------------------------------------------------------------------------- +// transfer_ownership +// --------------------------------------------------------------------------- + +#[test] +fn test_transfer_ownership_succeeds_with_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.transfer_ownership(&admin, &new_admin); + + let stored = client.get_admin().unwrap(); + assert_eq!(stored, new_admin); +} + +#[test] +fn test_transfer_ownership_rejects_non_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_transfer_ownership(&attacker, &new_admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Admin should remain unchanged. + assert_eq!(client.get_admin().unwrap(), admin); +} + +#[test] +fn test_transfer_ownership_rejects_self_transfer() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_transfer_ownership(&admin, &admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::AdminAddressInvalid); +} + +// --------------------------------------------------------------------------- +// add_admin +// --------------------------------------------------------------------------- + +#[test] +fn test_add_admin_succeeds_with_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.add_admin(&admin, &new_admin); + + let stored = client.get_admin().unwrap(); + assert_eq!(stored, new_admin); +} + +#[test] +fn test_add_admin_rejects_non_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_add_admin(&attacker, &new_admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); +} + +#[test] +fn test_add_admin_rejects_self() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_add_admin(&admin, &admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::AdminAddressInvalid); +} + +// --------------------------------------------------------------------------- +// Admin unset — the CRITICAL security fix +// --------------------------------------------------------------------------- + +#[test] +fn test_admin_unset_non_admin_rejected_before_init() { + let env = Env::default(); + env.mock_all_auths(); + let attacker = Address::generate(&env); + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + // Before initialization, get_admin returns None. + assert!(client.get_admin().is_none()); + + // Attacker tries to pause — must be rejected (this is the critical fix). + let result = client.try_pause_markets(&attacker); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Attacker tries transfer_ownership — must be rejected. + let some_addr = Address::generate(&env); + let result = client.try_transfer_ownership(&attacker, &some_addr); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Attacker tries add_admin — must be rejected. + let result = client.try_add_admin(&attacker, &some_addr); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); +} + +// --------------------------------------------------------------------------- +// Read-only entrypoints — no auth required +// --------------------------------------------------------------------------- + +#[test] +fn test_read_only_entrypoints_no_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + // These should all succeed without any auth. + assert!(client.get_admin().is_some()); + assert_eq!(client.is_paused(), false); + assert_eq!(client.version(), 7); +} diff --git a/contracts/markets/tests/proptest.rs b/contracts/markets/tests/proptest.rs index bd5cc740..45678cd7 100644 --- a/contracts/markets/tests/proptest.rs +++ b/contracts/markets/tests/proptest.rs @@ -64,7 +64,7 @@ proptest! { if time_advance < cooldown { // Should fail due to cooldown - prop_assert_eq!(res2.unwrap_err().unwrap(), ContractError::AdminActionTimelocked); + prop_assert_eq!(res2.unwrap_err().unwrap(), ContractError::AdminCooldownActive); } else { // Should succeed prop_assert!(res2.is_ok()); diff --git a/contracts/predictify-hybrid/src/delegation.rs b/contracts/predictify-hybrid/src/delegation.rs new file mode 100644 index 00000000..6241b4aa --- /dev/null +++ b/contracts/predictify-hybrid/src/delegation.rs @@ -0,0 +1,460 @@ +//! Voting delegation with per-address power cap. +//! +//! Implements `set_delegate` / `unset_delegate` entrypoints and enforces a +//! maximum delegation cap: no single address may accumulate delegated voting +//! power exceeding **10 % of the total voting power** of the market. +//! +//! # Design +//! +//! * `Delegation` record — stores the delegator→delegatee relationship and +//! the amount of voting power that was delegated. +//! * `DelegationManager` — all storage operations and cap enforcement. +//! * The cap is enforced at *delegation time* (`set_delegate`). If adding the +//! delegator's stake would push the delegatee's total delegated power above +//! the cap, the call is rejected with [`Error::DelegationCapExceeded`]. +//! * `unset_delegate` releases the delegated power and removes the record. +//! +//! # Storage layout +//! +//! | Key | Value | +//! |---------------------------------------|--------------| +//! | `Delegation(delegator)` | `Delegation` | +//! | `DelegatedPower(delegatee)` | `i128` | +//! | `TotalVotingPower` | `i128` | +//! +//! All keys live in **instance** storage so they are bumped automatically by +//! the Soroban runtime. + +use soroban_sdk::{contracttype, Address, Env, Map}; + +use crate::err::Error; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Maximum fraction (in basis points) of total voting power that a single +/// address may hold through delegation. 1 000 bps = 10 %. +pub const MAX_DELEGATION_BPS: i128 = 1_000; + +/// Denominator for basis-point calculations (100 %). +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// TTL threshold (ledgers) before delegation keys are bumped on read. +const DELEGATION_TTL_THRESHOLD: u32 = 5_356; + +/// Extended TTL (ledgers) for delegation storage entries (~31 days). +const DELEGATION_TTL_TO: u32 = 518_400; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Persistent record of an active delegation. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Delegation { + /// The address that delegated their voting power. + pub delegator: Address, + /// The address receiving the delegated power. + pub delegatee: Address, + /// Amount of voting power delegated (in stroops / base units). + pub amount: i128, +} + +/// Storage keys used by the delegation subsystem. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DelegationKey { + /// Active delegation record keyed by delegator address. + Delegation(Address), + /// Cumulative delegated power received by a delegatee. + DelegatedPower(Address), + /// Global running total of all voting power in the system. + TotalVotingPower, +} + +// --------------------------------------------------------------------------- +// Manager +// --------------------------------------------------------------------------- + +/// All delegation storage operations and cap enforcement. +pub struct DelegationManager; + +impl DelegationManager { + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /// Activate a delegation: the `delegator` transfers their `stake` amount + /// of voting power to `delegatee`. + /// + /// # Cap enforcement + /// The delegatee's new total delegated power must not exceed + /// `MAX_DELEGATION_BPS / BPS_DENOMINATOR` of [`TotalVotingPower`]. + /// + /// # Errors + /// * [`Error::InvalidInput`] — `stake` is not positive. + /// * [`Error::InvalidInput`] — delegator and delegatee are the same. + /// * [`Error::DelegationCapExceeded`] — cap would be exceeded. + pub fn set_delegate( + env: &Env, + delegator: &Address, + delegatee: &Address, + stake: i128, + ) -> Result<(), Error> { + delegator.require_auth(); + + if stake <= 0 { + return Err(Error::InvalidInput); + } + if delegator == delegatee { + return Err(Error::InvalidInput); + } + + // If the delegator already has an active delegation, remove the old + // one first so we don't double-count. + if let Some(existing) = Self::get_delegation(env, delegator) { + if existing.delegatee == *delegatee && existing.amount == stake { + // Idempotent: same delegation already active. + return Ok(()); + } + Self::remove_delegation_power(env, &existing.delegatee, existing.amount)?; + } + + // Check cap BEFORE writing. + let total_power = Self::get_total_voting_power(env); + let current_delegated = Self::get_delegated_power(env, delegatee); + let new_delegated = current_delegated + .checked_add(stake) + .ok_or(Error::Overflow)?; + + let cap = Self::delegation_cap(total_power); + if new_delegated > cap { + return Err(Error::DelegationCapExceeded); + } + + // Write the new delegation record. + let record = Delegation { + delegator: delegator.clone(), + delegatee: delegatee.clone(), + amount: stake, + }; + Self::store_delegation(env, &record); + Self::add_delegated_power(env, delegatee, stake)?; + + Ok(()) + } + + /// Remove an active delegation, releasing the delegated voting power. + /// + /// # Errors + /// * [`Error::InvalidInput`] — no active delegation for this delegator. + pub fn unset_delegate(env: &Env, delegator: &Address) -> Result<(), Error> { + delegator.require_auth(); + + let record = Self::get_delegation(env, delegator) + .ok_or(Error::InvalidInput)?; + + Self::remove_delegation_power(env, &record.delegatee, record.amount)?; + Self::delete_delegation(env, delegator); + + Ok(()) + } + + /// Read the active delegation for a delegator (read-only). + pub fn get_delegation(env: &Env, delegator: &Address) -> Option { + let key = DelegationKey::Delegation(delegator.clone()); + let val: Option = env.storage().instance().get(&key); + if val.is_some() { + Self::bump_ttl(env, &key); + } + val + } + + /// Total delegated power received by a delegatee (read-only). + pub fn get_delegated_power(env: &Env, delegatee: &Address) -> i128 { + let key = DelegationKey::DelegatedPower(delegatee.clone()); + let val: i128 = env + .storage() + .instance() + .get(&key) + .unwrap_or(0); + if val > 0 { + Self::bump_ttl(env, &key); + } + val + } + + /// Global total voting power (read-only). Defaults to 0 when unset. + pub fn get_total_voting_power(env: &Env) -> i128 { + let key = DelegationKey::TotalVotingPower; + let val: i128 = env.storage().instance().get(&key).unwrap_or(0); + if val > 0 { + Self::bump_ttl(env, &key); + } + val + } + + /// Update the global total voting power (e.g. when new votes are cast + /// outside the delegation system). Called by the voting module. + pub fn set_total_voting_power(env: &Env, total: i128) -> Result<(), Error> { + let key = DelegationKey::TotalVotingPower; + env.storage().instance().set(&key, &total); + Self::bump_ttl(env, &key); + Ok(()) + } + + /// Compute the per-address delegation cap given the total voting power. + /// + /// `cap = total_power * MAX_DELEGATION_BPS / BPS_DENOMINATOR` + pub fn delegation_cap(total_power: i128) -> i128 { + if total_power <= 0 { + return 0; + } + total_power + .checked_mul(MAX_DELEGATION_BPS) + .unwrap_or(i128::MAX) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + fn store_delegation(env: &Env, record: &Delegation) { + let key = DelegationKey::Delegation(record.delegator.clone()); + env.storage().instance().set(&key, record); + Self::bump_ttl(env, &key); + } + + fn delete_delegation(env: &Env, delegator: &Address) { + let key = DelegationKey::Delegation(delegator.clone()); + env.storage().instance().remove(&key); + } + + fn add_delegated_power( + env: &Env, + delegatee: &Address, + amount: i128, + ) -> Result<(), Error> { + let key = DelegationKey::DelegatedPower(delegatee.clone()); + let current: i128 = env.storage().instance().get(&key).unwrap_or(0); + let new_val = current.checked_add(amount).ok_or(Error::Overflow)?; + env.storage().instance().set(&key, &new_val); + Self::bump_ttl(env, &key); + Ok(()) + } + + fn remove_delegation_power( + env: &Env, + delegatee: &Address, + amount: i128, + ) -> Result<(), Error> { + let key = DelegationKey::DelegatedPower(delegatee.clone()); + let current: i128 = env.storage().instance().get(&key).unwrap_or(0); + let new_val = current.checked_sub(amount).unwrap_or(0); + env.storage().instance().set(&key, &new_val); + Self::bump_ttl(env, &key); + Ok(()) + } + + fn bump_ttl(env: &Env, key: &DelegationKey) { + env.storage() + .instance() + .extend_ttl(key, DELEGATION_TTL_THRESHOLD, DELEGATION_TTL_TO); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + fn setup_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env + } + + #[test] + fn test_set_delegate_basic() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + // Set total voting power so the cap is non-zero. + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + + let record = DelegationManager::get_delegation(&env, &delegator); + assert!(record.is_some()); + let record = record.unwrap(); + assert_eq!(record.delegator, delegator); + assert_eq!(record.delegatee, delegatee); + assert_eq!(record.amount, 50_000); + + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); + } + + #[test] + fn test_unset_delegate_releases_power() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); + + DelegationManager::unset_delegate(&env, &delegator).unwrap(); + + assert!(DelegationManager::get_delegation(&env, &delegator).is_none()); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 0); + } + + #[test] + fn test_cap_exactly_10_percent_allowed() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + // Total voting power = 1 000 000 → cap = 100 000 (10 %). + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + // Delegating exactly the cap should succeed. + DelegationManager::set_delegate(&env, &delegator, &delegatee, 100_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 100_000); + } + + #[test] + fn test_cap_exceeded_rejected() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + // Total voting power = 1 000 000 → cap = 100 000 (10 %). + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + // Delegating 100 001 (just over the cap) should fail. + let result = + DelegationManager::set_delegate(&env, &delegator, &delegatee, 100_001); + assert_eq!(result, Err(Error::DelegationCapExceeded)); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 0); + } + + #[test] + fn test_multiple_delegators_cap_enforced() { + let env = setup_env(); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + // First delegator: 60 000 (under cap). + let d1 = Address::generate(&env); + DelegationManager::set_delegate(&env, &d1, &delegatee, 60_000).unwrap(); + + // Second delegator: 40 000 → total 100 000 (exactly at cap, OK). + let d2 = Address::generate(&env); + DelegationManager::set_delegate(&env, &d2, &delegatee, 40_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 100_000); + + // Third delegator: 1 more → over cap, rejected. + let d3 = Address::generate(&env); + let result = DelegationManager::set_delegate(&env, &d3, &delegatee, 1); + assert_eq!(result, Err(Error::DelegationCapExceeded)); + } + + #[test] + fn test_redelegate_updates_power() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee_a = Address::generate(&env); + let delegatee_b = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + DelegationManager::set_delegate(&env, &delegator, &delegatee_a, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee_a), 50_000); + + // Re-delegate to a different delegatee. + DelegationManager::set_delegate(&env, &delegator, &delegatee_b, 30_000).unwrap(); + + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee_a), 0); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee_b), 30_000); + let record = DelegationManager::get_delegation(&env, &delegator).unwrap(); + assert_eq!(record.delegatee, delegatee_b); + assert_eq!(record.amount, 30_000); + } + + #[test] + fn test_set_delegate_rejects_self_delegation() { + let env = setup_env(); + let delegator = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + let result = DelegationManager::set_delegate(&env, &delegator, &delegator, 50_000); + assert_eq!(result, Err(Error::InvalidInput)); + } + + #[test] + fn test_set_delegate_rejects_zero_stake() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + let result = DelegationManager::set_delegate(&env, &delegator, &delegatee, 0); + assert_eq!(result, Err(Error::InvalidInput)); + } + + #[test] + fn test_unset_delegate_no_active_delegation() { + let env = setup_env(); + let delegator = Address::generate(&env); + + let result = DelegationManager::unset_delegate(&env, &delegator); + assert_eq!(result, Err(Error::InvalidInput)); + } + + #[test] + fn test_delegation_cap_zero_total_power() { + // When total voting power is 0, the cap is 0 — no delegation allowed. + assert_eq!(DelegationManager::delegation_cap(0), 0); + } + + #[test] + fn test_delegation_cap_calculation() { + // 10 % of 1 000 000 = 100 000 + assert_eq!(DelegationManager::delegation_cap(1_000_000), 100_000); + // 10 % of 500 = 50 + assert_eq!(DelegationManager::delegation_cap(500), 50); + // 10 % of 10 = 1 + assert_eq!(DelegationManager::delegation_cap(10), 1); + } + + #[test] + fn test_idempotent_same_delegation() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + // First call stores the delegation. + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); + + // Second call with same params is idempotent. + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); + } +} diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 30436d98..0502b311 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -24,6 +24,9 @@ pub enum Error { InvalidCap = 674, /// The cool-off period is still active. Cannot unpause/resume the market. CoolOffPeriodActive = 675, + /// Delegation cap exceeded: a single address cannot hold more than 10 % of + /// total voting power through delegation. + DelegationCapExceeded = 676, // ===== USER OPERATION ERRORS (100-112) ===== /// User is not authorized to perform the requested action. Typically returned when /// a non-admin attempts to call admin-only functions. diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index d296a0b3..3388e8d8 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -34,6 +34,7 @@ pub mod gov_registry; mod fees; mod gas; mod governance; +mod delegation; mod markets; mod monitoring; mod oracles; diff --git a/contracts/predictify-hybrid/tests/err_stability.rs b/contracts/predictify-hybrid/tests/err_stability.rs index cdb11c1c..89672e76 100644 --- a/contracts/predictify-hybrid/tests/err_stability.rs +++ b/contracts/predictify-hybrid/tests/err_stability.rs @@ -150,11 +150,12 @@ error_code_snapshot! { MaxBetCapExceeded = 673, InvalidCap = 674, CoolOffPeriodActive = 675, + DelegationCapExceeded = 676, } #[test] fn contract_error_codes_are_stable() { - assert_eq!(ERROR_CODE_SNAPSHOT.len(), 120); + assert_eq!(ERROR_CODE_SNAPSHOT.len(), 121); for &(error, expected) in ERROR_CODE_SNAPSHOT { assert_eq!(