From 4ec69ea0180001eadd8395e1c9b20f73d975c4bd Mon Sep 17 00:00:00 2001 From: chiomailekuba <229507446+chiomailekuba@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:01:15 +0100 Subject: [PATCH 1/2] feat(creator-keys): add graduated bonding curve with property-based tests --- creator-keys/src/events.rs | 20 ++ creator-keys/src/lib.rs | 148 +++++++++ .../tests/graduated_curve_property_tests.rs | 283 ++++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 creator-keys/tests/graduated_curve_property_tests.rs diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 502061e7..8592035b 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -3008,3 +3008,23 @@ pub struct EscalationConfigUpdatedEvent { pub fn escalation_config_updated_topics(admin: &Address) -> (Symbol, Address) { (ESCALATION_CONFIG_UPDATED_EVENT_NAME, admin.clone()) } + +/// Event name for configuring a graduated bonding curve with supply milestones. +pub const GRADUATED_CURVE_CONFIGURED_EVENT_NAME: Symbol = symbol_short!("grad_crv"); + +/// Stable graduated curve configured event payload for downstream indexers. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct GraduatedCurveConfiguredEvent { + /// Creator whose curve was configured. + pub creator: Address, + /// All milestone `(supply_threshold, exponent)` pairs. + pub milestones: Vec<(u32, u32)>, + /// Ledger sequence at the time of configuration. + pub ledger: u32, +} + +/// Shared graduated curve configured event topics tuple. +pub fn graduated_curve_configured_topics(creator: &Address) -> (Symbol, Address) { + (GRADUATED_CURVE_CONFIGURED_EVENT_NAME, creator.clone()) +} diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 233fa274..cee488ce 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -311,6 +311,23 @@ pub enum EscalationError { NotRegistered = 10, } +/// Errors raised by graduated bonding curve configuration entrypoints. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum CurveConfigError { + /// Milestones vector is empty or exceeds the maximum of 5. + InvalidMilestoneCount = 1, + /// Supply thresholds must be strictly ascending. + ThresholdNotAscending = 2, + /// Exponent must be in the range 1..=5. + InvalidExponent = 3, + /// The creator address is not registered. + NotRegistered = 4, + /// Arithmetic overflow occurred during curve calculation. + Overflow = 5, +} + pub mod fee { use crate::ContractError; @@ -575,6 +592,10 @@ pub mod constants { DataKey::CurvePreset(creator.clone()) } + pub fn graduated_curve(creator: &Address) -> DataKey { + DataKey::GraduatedCurve(creator.clone()) + } + pub fn creator_fee_balance(creator: &Address) -> DataKey { DataKey::CreatorFeeBalance(creator.clone()) } @@ -1591,6 +1612,8 @@ pub enum DataKey { BuybackPoolAddress, /// Protocol-wide poll quorum-escalation configuration. EscalationConfig, + /// Graduated bonding curve milestones for a creator. + GraduatedCurve(Address), } #[derive(Clone, Debug, PartialEq)] @@ -3089,12 +3112,82 @@ fn read_curve_exponent(env: &Env, creator: &Address) -> Option { .get(&constants::storage::curve_exponent(creator)) } +fn read_graduated_curve(env: &Env, creator: &Address) -> Option> { + env.storage() + .persistent() + .get(&constants::storage::graduated_curve(creator)) +} + +pub fn graduated_exponent_for_supply(milestones: &Vec<(u32, u32)>, supply: u32) -> u32 { + let mut last_exponent = 1u32; + for (threshold, exponent) in milestones.iter() { + last_exponent = exponent; + if supply <= threshold { + return exponent; + } + } + last_exponent +} + +pub fn compute_graduated_curve_price( + milestones: &Vec<(u32, u32)>, + base_price: i128, + slope: i128, + supply: u32, +) -> Result { + if milestones.is_empty() { + return Ok(base_price); + } + + let mut current_base = base_price; + let mut prev_threshold = 0u32; + let mut last_exponent = 1u32; + + for (threshold, exponent) in milestones.iter() { + last_exponent = exponent; + if supply <= threshold { + let delta = (supply - prev_threshold) as i128; + let delta_exp = checked_pow_i128(delta, exponent)?; + let delta_component = slope + .checked_mul(delta_exp) + .ok_or(ContractError::Overflow)?; + return current_base + .checked_add(delta_component) + .ok_or(ContractError::Overflow); + } else { + let span = (threshold - prev_threshold) as i128; + let span_exp = checked_pow_i128(span, exponent)?; + let span_component = slope + .checked_mul(span_exp) + .ok_or(ContractError::Overflow)?; + current_base = current_base + .checked_add(span_component) + .ok_or(ContractError::Overflow)?; + prev_threshold = threshold; + } + } + + let delta = (supply - prev_threshold) as i128; + let delta_exp = checked_pow_i128(delta, last_exponent)?; + let delta_component = slope + .checked_mul(delta_exp) + .ok_or(ContractError::Overflow)?; + current_base + .checked_add(delta_component) + .ok_or(ContractError::Overflow) +} + fn compute_bonding_curve_price( env: &Env, creator: &Address, base_price: i128, supply: u32, ) -> Result { + if let Some(milestones) = read_graduated_curve(env, creator) { + let slope = read_curve_slope(env); + return compute_graduated_curve_price(&milestones, base_price, slope, supply); + } + if let Some(exponent) = read_curve_exponent(env, creator) { let slope = read_curve_slope(env); let supply_exp = checked_pow_i128(supply as i128, exponent)?; @@ -11802,6 +11895,61 @@ impl CreatorKeysContract { read_curve_exponent(&env, &creator) } + /// Sets a graduated bonding curve with supply milestones for a creator. + /// + /// Requires authorization from `creator`. Milestones must have 1..=5 tiers, + /// thresholds must be strictly ascending, and exponents must be in 1..=5. + pub fn set_graduated_curve( + env: Env, + creator: Address, + milestones: Vec<(u32, u32)>, + ) -> Result<(), CurveConfigError> { + creator.require_auth(); + read_registered_creator_profile(&env, &creator) + .map_err(|_| CurveConfigError::NotRegistered)?; + + if milestones.is_empty() || milestones.len() > 5 { + return Err(CurveConfigError::InvalidMilestoneCount); + } + + let mut prev_threshold = 0u32; + for (threshold, exponent) in milestones.iter() { + if threshold <= prev_threshold { + return Err(CurveConfigError::ThresholdNotAscending); + } + if !(1..=5).contains(&exponent) { + return Err(CurveConfigError::InvalidExponent); + } + prev_threshold = threshold; + } + + let key = constants::storage::graduated_curve(&creator); + env.storage().persistent().set(&key, &milestones); + extend_key_ttl_to_full_window(&env, &key); + + env.events().publish( + events::graduated_curve_configured_topics(&creator), + events::GraduatedCurveConfiguredEvent { + creator: creator.clone(), + milestones, + ledger: env.ledger().sequence(), + }, + ); + + Ok(()) + } + + /// Read-only view: returns the graduated bonding curve milestones for a creator, if set. + pub fn get_graduated_curve(env: Env, creator: Address) -> Option> { + read_graduated_curve(&env, &creator) + } + + /// Read-only view: returns the active graduated exponent for a creator at a given supply. + pub fn get_graduated_exponent(env: Env, creator: Address, supply: u32) -> Option { + let milestones = read_graduated_curve(&env, &creator)?; + Some(graduated_exponent_for_supply(&milestones, supply)) + } + pub fn get_stake_unlock_ledger(env: Env, creator: Address, holder: Address) -> Option { env.storage() .persistent() diff --git a/creator-keys/tests/graduated_curve_property_tests.rs b/creator-keys/tests/graduated_curve_property_tests.rs new file mode 100644 index 00000000..436217b4 --- /dev/null +++ b/creator-keys/tests/graduated_curve_property_tests.rs @@ -0,0 +1,283 @@ +//! Property-based tests and contract integration tests for graduated bonding curves (#861). +//! +//! Validates: +//! 1. Monotonicity: P(N) >= P(N - 1) across arbitrary supplies and milestone configurations. +//! 2. Boundary Smoothness: No discontinuous drops across milestone thresholds. +//! 3. Total Cost Invariant: sum_{i=S}^{S+Q-1} P(i) >= Q * P(S). +//! 4. Contract Configuration: validation guards, storage, event publishing, and query_price integration. + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, test_env_with_auths, +}; +use creator_keys::{ + compute_graduated_curve_price, graduated_exponent_for_supply, CurveConfigError, +}; +use soroban_sdk::{ + testutils::Address as _, + Address, Env, Vec as SorobanVec, +}; + +/// Deterministic pseudo-random number generator for reproducible property tests. +struct SimpleRng { + state: u64, +} + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self { + state: if seed == 0 { 0xdeadbeef_cafebabe } else { seed }, + } + } + + fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.state = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + + fn gen_range(&mut self, min: u64, max: u64) -> u64 { + if min >= max { + return min; + } + min + (self.next_u64() % (max - min + 1)) + } +} + +#[test] +fn test_graduated_curve_property_suite_10000_iterations() { + let env = Env::default(); + env.cost_estimate().budget().reset_unlimited(); + let seed: u64 = 0x861_0849_2026_0924; + let mut rng = SimpleRng::new(seed); + + const ITERATIONS: usize = 10_000; + + for i in 0..ITERATIONS { + let milestone_count = rng.gen_range(1, 5) as usize; + let mut milestones_raw = std::vec::Vec::new(); + let mut prev_threshold = 0u32; + + for _ in 0..milestone_count { + let step = rng.gen_range(5, 500) as u32; + let threshold = prev_threshold + step; + let exponent = rng.gen_range(1, 5) as u32; + milestones_raw.push((threshold, exponent)); + prev_threshold = threshold; + } + + let mut milestones: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + for m in milestones_raw.iter() { + milestones.push_back(*m); + } + + let base_price = rng.gen_range(1, 10_000_000) as i128; + let slope = rng.gen_range(1, 1_000_000) as i128; + + // --- Property 1: Monotonicity --- + for _ in 0..3 { + let max_supply = (prev_threshold + 500) as u64; + let s = rng.gen_range(1, max_supply) as u32; + + let p_curr = compute_graduated_curve_price(&milestones, base_price, slope, s); + let p_prev = compute_graduated_curve_price(&milestones, base_price, slope, s - 1); + + match (p_curr, p_prev) { + (Ok(curr), Ok(prev)) => { + assert!( + curr >= prev, + "Monotonicity violated at seed: {}, iter: {}, supply: {}, P(S)={} < P(S-1)={}", + seed, + i, + s, + curr, + prev + ); + } + (Err(_), _) => { + // Overflow is acceptable at astronomical numbers, but should never occur within our test range + } + _ => {} + } + } + + // --- Property 2: Boundary Smoothness across Milestone Transitions --- + for &(threshold, _) in milestones_raw.iter() { + if threshold >= 1 { + let p_before = compute_graduated_curve_price(&milestones, base_price, slope, threshold - 1) + .unwrap_or(0); + let p_at = compute_graduated_curve_price(&milestones, base_price, slope, threshold) + .unwrap_or(0); + let p_after = compute_graduated_curve_price(&milestones, base_price, slope, threshold + 1) + .unwrap_or(0); + + assert!( + p_at >= p_before, + "Discontinuity before threshold {} at seed: {}, iter: {}: P(T)={} < P(T-1)={}", + threshold, + seed, + i, + p_at, + p_before + ); + assert!( + p_after >= p_at, + "Discontinuity after threshold {} at seed: {}, iter: {}: P(T+1)={} < P(T)={}", + threshold, + seed, + i, + p_after, + p_at + ); + } + } + + // --- Property 3: Total Cost Invariant sum_{k=0}^{Q-1} P(S+k) >= Q * P(S) --- + let start_s = rng.gen_range(0, (prev_threshold + 100) as u64) as u32; + let qty = rng.gen_range(1, 20) as u32; + + let p_start_res = compute_graduated_curve_price(&milestones, base_price, slope, start_s); + if let Ok(p_start) = p_start_res { + let mut total_cost: i128 = 0; + let mut overflowed = false; + + for q in 0..qty { + match compute_graduated_curve_price(&milestones, base_price, slope, start_s + q) { + Ok(p_step) => { + if let Some(next_total) = total_cost.checked_add(p_step) { + total_cost = next_total; + } else { + overflowed = true; + break; + } + } + Err(_) => { + overflowed = true; + break; + } + } + } + + if !overflowed { + let min_expected = (qty as i128) * p_start; + assert!( + total_cost >= min_expected, + "Total cost invariant violated at seed: {}, iter: {}: total_cost={} < Q * P(S)={}", + seed, + i, + total_cost, + min_expected + ); + } + } + + // --- Exponent Resolution Verification --- + for &(threshold, exp) in milestones_raw.iter() { + let active_exp = graduated_exponent_for_supply(&milestones, threshold); + assert_eq!( + active_exp, exp, + "Exponent mismatch at threshold {} at seed: {}, iter: {}", + threshold, seed, i + ); + } + } +} + +#[test] +fn test_graduated_curve_validation_and_storage() { + let env = test_env_with_auths(); + let (client, _contract_id) = register_creator_keys(&env); + let creator = register_test_creator(&env, &client, "creator"); + let admin = Address::generate(&env); + client.set_protocol_admin(&admin, &admin); + + // 1. Empty milestones rejected + let empty_milestones: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + let res = client.try_set_graduated_curve(&creator, &empty_milestones); + assert_eq!( + res.err().unwrap().unwrap(), + CurveConfigError::InvalidMilestoneCount + ); + + // 2. More than 5 milestones rejected + let mut too_many: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + for k in 1..=6 { + too_many.push_back((k * 100, 2)); + } + let res = client.try_set_graduated_curve(&creator, &too_many); + assert_eq!( + res.err().unwrap().unwrap(), + CurveConfigError::InvalidMilestoneCount + ); + + // 3. Non-ascending thresholds rejected + let mut non_ascending: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + non_ascending.push_back((500, 1)); + non_ascending.push_back((300, 2)); + let res = client.try_set_graduated_curve(&creator, &non_ascending); + assert_eq!( + res.err().unwrap().unwrap(), + CurveConfigError::ThresholdNotAscending + ); + + // 4. Invalid exponent rejected (0 or > 5) + let mut bad_exponent: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + bad_exponent.push_back((100, 0)); + let res = client.try_set_graduated_curve(&creator, &bad_exponent); + assert_eq!( + res.err().unwrap().unwrap(), + CurveConfigError::InvalidExponent + ); + + let mut bad_exponent_high: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + bad_exponent_high.push_back((100, 6)); + let res = client.try_set_graduated_curve(&creator, &bad_exponent_high); + assert_eq!( + res.err().unwrap().unwrap(), + CurveConfigError::InvalidExponent + ); + + // 5. Unregistered creator rejected + let unregistered = Address::generate(&env); + let mut valid_milestones: SorobanVec<(u32, u32)> = SorobanVec::new(&env); + valid_milestones.push_back((100, 1)); + valid_milestones.push_back((500, 2)); + let res = client.try_set_graduated_curve(&unregistered, &valid_milestones); + assert_eq!(res.err().unwrap().unwrap(), CurveConfigError::NotRegistered); + + // 6. Valid configuration succeeds and persists + client.set_graduated_curve(&creator, &valid_milestones); + let stored = client.get_graduated_curve(&creator); + assert!(stored.is_some()); + assert_eq!(stored.unwrap(), valid_milestones); + + // Exponent view query + assert_eq!(client.get_graduated_exponent(&creator, &50), Some(1)); + assert_eq!(client.get_graduated_exponent(&creator, &100), Some(1)); + assert_eq!(client.get_graduated_exponent(&creator, &101), Some(2)); + assert_eq!(client.get_graduated_exponent(&creator, &500), Some(2)); + assert_eq!(client.get_graduated_exponent(&creator, &1000), Some(2)); + + // Pricing integration test + client.set_key_price(&admin, &1_000); + client.set_curve_slope(&admin, &10); + + let p0 = client.query_price(&creator, &0); + assert_eq!(p0, 1_000); + + let p50 = client.query_price(&creator, &50); + // delta = 50, exp = 1 => 1000 + 10 * 50 = 1500 + assert_eq!(p50, 1_500); + + let p100 = client.query_price(&creator, &100); + // delta = 100, exp = 1 => 1000 + 10 * 100 = 2000 + assert_eq!(p100, 2_000); + + let p102 = client.query_price(&creator, &102); + // base at 100 is 2000. delta = 2, exp = 2 => 2^2 = 4. 2000 + 10 * 4 = 2040 + assert_eq!(p102, 2_040); +} From ab1dd0063c6807109f230160d6159191af3f9fdd Mon Sep 17 00:00:00 2001 From: chiomailekuba <229507446+chiomailekuba@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:14:03 +0100 Subject: [PATCH 2/2] style(creator-keys): apply cargo fmt to graduated curve changes Fixes the Format check step in Contracts CI, which failed on the multi-line / line-length formatting of compute_graduated_curve_price and the property test imports. Co-Authored-By: Claude Opus 5 --- creator-keys/src/lib.rs | 4 +--- .../tests/graduated_curve_property_tests.rs | 19 ++++++++----------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index cee488ce..db21cd9a 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -3157,9 +3157,7 @@ pub fn compute_graduated_curve_price( } else { let span = (threshold - prev_threshold) as i128; let span_exp = checked_pow_i128(span, exponent)?; - let span_component = slope - .checked_mul(span_exp) - .ok_or(ContractError::Overflow)?; + let span_component = slope.checked_mul(span_exp).ok_or(ContractError::Overflow)?; current_base = current_base .checked_add(span_component) .ok_or(ContractError::Overflow)?; diff --git a/creator-keys/tests/graduated_curve_property_tests.rs b/creator-keys/tests/graduated_curve_property_tests.rs index 436217b4..efe7dcfb 100644 --- a/creator-keys/tests/graduated_curve_property_tests.rs +++ b/creator-keys/tests/graduated_curve_property_tests.rs @@ -8,16 +8,11 @@ mod contract_test_env; -use contract_test_env::{ - register_creator_keys, register_test_creator, test_env_with_auths, -}; +use contract_test_env::{register_creator_keys, register_test_creator, test_env_with_auths}; use creator_keys::{ compute_graduated_curve_price, graduated_exponent_for_supply, CurveConfigError, }; -use soroban_sdk::{ - testutils::Address as _, - Address, Env, Vec as SorobanVec, -}; +use soroban_sdk::{testutils::Address as _, Address, Env, Vec as SorobanVec}; /// Deterministic pseudo-random number generator for reproducible property tests. struct SimpleRng { @@ -108,12 +103,14 @@ fn test_graduated_curve_property_suite_10000_iterations() { // --- Property 2: Boundary Smoothness across Milestone Transitions --- for &(threshold, _) in milestones_raw.iter() { if threshold >= 1 { - let p_before = compute_graduated_curve_price(&milestones, base_price, slope, threshold - 1) - .unwrap_or(0); + let p_before = + compute_graduated_curve_price(&milestones, base_price, slope, threshold - 1) + .unwrap_or(0); let p_at = compute_graduated_curve_price(&milestones, base_price, slope, threshold) .unwrap_or(0); - let p_after = compute_graduated_curve_price(&milestones, base_price, slope, threshold + 1) - .unwrap_or(0); + let p_after = + compute_graduated_curve_price(&milestones, base_price, slope, threshold + 1) + .unwrap_or(0); assert!( p_at >= p_before,