diff --git a/leaderboard/src/lib.rs b/leaderboard/src/lib.rs index 5a2135f..af8aaae 100644 --- a/leaderboard/src/lib.rs +++ b/leaderboard/src/lib.rs @@ -202,6 +202,9 @@ impl LeaderboardContract { Self::write_token_contract(&env, &admin, &token) } + // ── Bet-settlement path ─────────────────────────────────────────────────── + + /// Called by the market contract after a bet is settled. /// The cross-contract ABI version this deployment implements (issue #84). pub fn interface_version(_env: Env) -> u32 { INTERFACE_VERSION @@ -230,6 +233,9 @@ impl LeaderboardContract { .unwrap_or(false) } + /// Original ABI name — kept for callers that deploy against the pre-#23 + /// interface (prediction_market and referral_registry tests use it). + pub fn set_token( // ── Bet-settlement path ─────────────────────────────────────────────────── /// Called by the market contract after a bet is settled. Credits points, @@ -361,6 +367,27 @@ impl LeaderboardContract { env.storage().persistent().get(&DataKey::PendingReward(user)) } + pub fn add_pts( + env: Env, + caller: Address, + user: Address, + pts: u64, + tokens: i128, + is_won: bool, + ) -> Result<(), LeaderboardError> { + Self::require_not_paused(&env)?; + let market: Address = env + .storage() + .instance() + .get(&DataKey::MarketContract) + .ok_or(LeaderboardError::NotInitialized)?; + if caller != market { + return Err(LeaderboardError::UnauthorizedCaller); + } + caller.require_auth(); + Self::credit_points(&env, &user, pts, Some(is_won)); + Ok(()) + } // ── reward_bonus / add_bonus_pts (referral path) ───────────────────────── /// Called by the referral contract for welcome / per-bet referral bonuses. diff --git a/pulse_token/src/lib.rs b/pulse_token/src/lib.rs index f9b3920..ab9b21a 100644 --- a/pulse_token/src/lib.rs +++ b/pulse_token/src/lib.rs @@ -36,10 +36,12 @@ pub enum TokenError { InsufficientAllowance = 7, InvalidExpirationLedger = 8, // Issue #95: operation blocked because the contract is paused. + Paused = 9, ContractPaused = 9, AlreadyMinter = 10, NotMinter = 11, MinterListFull = 12, + SupplyCapExceeded = 13, } #[contracttype] @@ -57,6 +59,7 @@ pub enum DataKey { MinterAt(u32), MinterCount, MinterIndex(Address), + SupplyCap, // i128 — maximum total_supply (0 = unlimited) } #[contracttype] @@ -115,6 +118,36 @@ impl PULSETokenContract { INTERFACE_VERSION } + // ── Supply cap (issue #79) ──────────────────────────────────────────── + // A cap of 0 means unlimited (backwards-compatible default). + // Admin only; emits a SupplyCapSet event so indexers can track policy changes. + + /// Set or clear the maximum total_supply. Admin only. + pub fn set_supply_cap(env: Env, admin: Address, cap: i128) -> Result<(), TokenError> { + let stored = Self::require_admin(&env)?; + if admin != stored { + return Err(TokenError::NotAdmin); + } + admin.require_auth(); + if cap < 0 { + return Err(TokenError::InvalidAmount); + } + env.storage().instance().set(&DataKey::SupplyCap, &cap); + env.events().publish( + (Symbol::new(&env, "supply_cap_set"), admin), + cap, + ); + Ok(()) + } + + /// Current supply cap. 0 means unlimited. + pub fn get_supply_cap(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::SupplyCap) + .unwrap_or(0) + } + /// Halt mint/transfer/burn in an emergency. Admin only. View functions /// (balance, total_supply, ...) keep working so integrators can still /// read state while the contract is paused. @@ -268,6 +301,32 @@ impl PULSETokenContract { if !is_minter { return Err(TokenError::UnauthorizedMinter); } + // Issue #79: enforce supply cap BEFORE any state change. + // Check must happen first — if cap is exceeded, no balance or + // supply should be modified. + let supply: i128 = env + .storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0); + let cap: i128 = env + .storage() + .instance() + .get(&DataKey::SupplyCap) + .unwrap_or(0); + if cap > 0 && supply + amount > cap { + return Err(TokenError::SupplyCapExceeded); + } + // Cap OK — now apply state changes. + let balance = Self::balance(env.clone(), to.clone()); + let to_key = DataKey::Balance(to.clone()); + env.storage() + .persistent() + .set(&to_key, &(balance + amount)); + env.storage() + .persistent() + .extend_ttl(&to_key, TTL_BUMP, TTL_HIGH); + env.storage().instance().set(&DataKey::TotalSupply, &(supply + amount)); // An authorization grant that expires silently disables the minter // (e.g. the leaderboard paying out rewards), so refresh it on use. env.storage() @@ -509,7 +568,7 @@ impl PULSETokenContract { fn require_not_paused(env: &Env) -> Result<(), TokenError> { if Self::is_paused(env.clone()) { - return Err(TokenError::ContractPaused); + return Err(TokenError::Paused); } Ok(()) } diff --git a/pulse_token/src/tests.rs b/pulse_token/src/tests.rs index 0a006aa..e069cc1 100644 --- a/pulse_token/src/tests.rs +++ b/pulse_token/src/tests.rs @@ -732,3 +732,123 @@ fn test_balance_survives_beyond_original_ttl_and_supply_stays_consistent() { assert!(balance_ttl(&env, &client.address, &bob) >= TTL_BUMP); assert!(instance_ttl(&env, &client.address) >= TTL_BUMP); } + +// ═══════════════════════════════════════════════════════════════════════════ +// Issue #79 — supply cap prevents unbounded PULSE inflation +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_supply_cap_default_unlimited() { + let env = Env::default(); + env.mock_all_auths(); + let client = setup(&env); + let _admin = init(&env, &client); + let minter = Address::generate(&env); + let alice = Address::generate(&env); + client.set_minter(&minter); + + // Default cap is 0 (unlimited) — minting should work. + assert_eq!(client.get_supply_cap(), 0); + client.mint(&minter, &alice, &1_000_0000000_i128); + assert_eq!(client.total_supply(), 1_000_0000000_i128); +} + +#[test] +fn test_set_supply_cap() { + let env = Env::default(); + env.mock_all_auths(); + let client = setup(&env); + let admin = init(&env, &client); + let minter = Address::generate(&env); + let alice = Address::generate(&env); + client.set_minter(&minter); + + // Set cap to 100 PULSE. + client.set_supply_cap(&admin, &100_0000000_i128); + assert_eq!(client.get_supply_cap(), 100_0000000_i128); + + // Mint 50 PULSE — should succeed. + client.mint(&minter, &alice, &50_0000000_i128); + assert_eq!(client.total_supply(), 50_0000000_i128); + + // Mint 60 more PULSE — total would be 110, exceeding cap. + assert!(client.try_mint(&minter, &alice, &60_0000000_i128).is_err()); + assert_eq!(client.total_supply(), 50_0000000_i128); // unchanged +} + +#[test] +fn test_supply_cap_exact_boundary() { + let env = Env::default(); + env.mock_all_auths(); + let client = setup(&env); + let admin = init(&env, &client); + let minter = Address::generate(&env); + let alice = Address::generate(&env); + client.set_minter(&minter); + + // Set cap to 100 PULSE. + client.set_supply_cap(&admin, &100_0000000_i128); + + // Mint exactly 100 PULSE — should succeed. + client.mint(&minter, &alice, &100_0000000_i128); + assert_eq!(client.total_supply(), 100_0000000_i128); + + // Mint 1 stroop more — should fail. + assert!(client.try_mint(&minter, &alice, &1).is_err()); + assert_eq!(client.total_supply(), 100_0000000_i128); // unchanged +} + +#[test] +fn test_supply_cap_clear() { + let env = Env::default(); + env.mock_all_auths(); + let client = setup(&env); + let admin = init(&env, &client); + let minter = Address::generate(&env); + let alice = Address::generate(&env); + client.set_minter(&minter); + + // Set cap, mint up to it. + client.set_supply_cap(&admin, &10_0000000_i128); + client.mint(&minter, &alice, &10_0000000_i128); + assert!(client.try_mint(&minter, &alice, &1).is_err()); + + // Clear cap (set to 0) — minting resumes. + client.set_supply_cap(&admin, &0); + assert_eq!(client.get_supply_cap(), 0); + client.mint(&minter, &alice, &1_0000000_i128); + assert_eq!(client.total_supply(), 11_0000000_i128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_set_supply_cap_requires_admin() { + let env = Env::default(); + env.mock_all_auths(); + let client = setup(&env); + let _admin = init(&env, &client); + let rando = Address::generate(&env); + + // Non-admin must fail with NotAdmin. + client.set_supply_cap(&rando, &1_000_0000000_i128); +} + +#[test] +fn test_supply_cap_balance_unchanged_on_reject() { + let env = Env::default(); + env.mock_all_auths(); + let client = setup(&env); + let admin = init(&env, &client); + let minter = Address::generate(&env); + let alice = Address::generate(&env); + client.set_minter(&minter); + + // Set cap and mint up to it. + client.set_supply_cap(&admin, &10_0000000_i128); + client.mint(&minter, &alice, &10_0000000_i128); + + // Attempt to exceed cap — balance must remain unchanged. + let balance_before = client.balance(&alice); + assert!(client.try_mint(&minter, &alice, &1_0000000_i128).is_err()); + assert_eq!(client.balance(&alice), balance_before); // no state corruption +}