From c08351278a82a2041e1440095a3b00644ec65e48 Mon Sep 17 00:00:00 2001 From: hunter-baddie Date: Tue, 30 Jun 2026 00:18:50 +0100 Subject: [PATCH] feat: add max_total_supply_shares hard cap independent of bps --- CLOSE_PERIOD_GAS_TEST_SUMMARY.md | 36 ++++++++ src/lib.rs | 140 +++++++++++++++++++++++++++++++ src/test_close_period.rs | 98 ++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 CLOSE_PERIOD_GAS_TEST_SUMMARY.md diff --git a/CLOSE_PERIOD_GAS_TEST_SUMMARY.md b/CLOSE_PERIOD_GAS_TEST_SUMMARY.md new file mode 100644 index 00000000..36a424f2 --- /dev/null +++ b/CLOSE_PERIOD_GAS_TEST_SUMMARY.md @@ -0,0 +1,36 @@ +# Close Period Gas Test Summary + +## Purpose + +This document describes the gas-bound tests for the `close_period` function to ensure it maintains a linear O(holders) time complexity with no hidden quadratic paths. + +## Test Cases + +1. **`close_period_cpu_grows_linearly_with_holders`**: + - Parameterized over holder counts [1, 10, 100, 1000] + - Measures CPU instructions consumed per call + - Fits a linear regression line and verifies R² (coefficient of determination) > 0.98 + - Ensures cost grows linearly with number of holders + +2. **`close_period_zero_holders_has_constant_cost`**: + - Tests closing a period with 0 holders + - Verifies cost is positive but bounded by a constant (<5,000,000 instructions) + +## Linearity Check + +Uses coefficient of determination (R²) to measure how well the data fits a linear model: +- R² > 0.98 means the data is well explained by a linear relationship +- Calculates slope, intercept, and residual sum of squares +- Handles edge cases like zero variance in y-values + +## Key Assumptions + +- `close_period` currently has constant O(1) cost (no holder iteration) +- If future modifications add holder iteration, these tests will catch O(n²) or worse regressions +- Uses Soroban's built-in `env.budget().cpu_instruction_count()` for accurate measurements + +## Security Notes + +- Linear cost ensures scalability for offerings with many holders +- Prevents gas bombs from accidental quadratic loops +- Tests are designed to fail fast if performance degrades diff --git a/src/lib.rs b/src/lib.rs index afd6a619..fdd0def7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,6 +157,8 @@ pub enum RevoraError { BlacklistSizeLimitExceeded = 45, /// Approver has already approved this proposal. AlreadyApproved = 46, + /// Total supply shares would exceed the offering's max total supply shares. + MaxTotalSupplySharesExceeded = 51, /// override_existing=true was requested but no persisted report exists for the given period_id. /// This prevents falling through to initial-report handling when the period cursor has no @@ -767,6 +769,15 @@ pub enum DataKey2 { /// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period. ClosedPeriod(OfferingId, u64), + + /// Per-offering supply cap (revenue deposit limit). + SupplyCap(OfferingId), + /// Per-offering total deposited revenue. + DepositedRevenue(OfferingId), + /// Per-offering max total supply shares (share unit cap). + MaxTotalSupplyShares(OfferingId), + /// Per-offering total issued shares (sum of holder shares in units). + TotalSharesIssued(OfferingId), } /// Maximum number of offerings returned in a single page. @@ -831,6 +842,9 @@ pub enum AmountValidationCategory { /// Generic distribution simulation: any i128 is valid (can be negative for modeling). /// Reason: Simulation-only, no state mutation. Simulation, + /// Max total supply shares configuration: must be non-negative (>= 0). + /// Reason: Zero cap means unlimited; negative cap is invalid. + MaxTotalSupplyShares, } /// Result of amount validation with detailed classification. @@ -937,6 +951,11 @@ impl AmountValidationMatrix { } } AmountValidationCategory::Simulation => {} + AmountValidationCategory::MaxTotalSupplyShares => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } } Ok(()) } @@ -1011,6 +1030,7 @@ impl AmountValidationMatrix { "set_min_revenue_threshold" => Some(AmountValidationCategory::MinRevenueThreshold), "set_investment_constraints" => Some(AmountValidationCategory::InvestmentMinStake), "simulate_distribution" => Some(AmountValidationCategory::Simulation), + "set_max_total_supply_shares" => Some(AmountValidationCategory::MaxTotalSupplyShares), _ => None, } } @@ -1236,6 +1256,23 @@ impl RevoraRevenueShare { token: token.clone(), }; + // Check max total supply shares cap + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); + let max_shares: i128 = env.storage().persistent().get(&max_shares_key).unwrap_or(0); + if max_shares > 0 { + let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); + let current_total_shares: i128 = env.storage().persistent().get(&total_shares_key).unwrap_or(0); + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + let new_total_shares = current_total_shares.saturating_sub(old_share as i128).saturating_add(share_bps as i128); + if new_total_shares > max_shares { + return Err(RevoraError::MaxTotalSupplySharesExceeded); + } + } + // Maintain a running total of persisted holder shares for this offering. let total_key = DataKey::HolderShareTotal(offering_id.clone()); let mut current_total: u32 = env.storage().persistent().get(&total_key).unwrap_or(0); @@ -1251,6 +1288,12 @@ impl RevoraRevenueShare { return Err(RevoraError::InvalidShareBps); } + // Update total shares issued + let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); + let current_total_shares: i128 = env.storage().persistent().get(&total_shares_key).unwrap_or(0); + let new_total_shares = current_total_shares.saturating_sub(old_share as i128).saturating_add(share_bps as i128); + env.storage().persistent().set(&total_shares_key, &new_total_shares); + // Persist updated holder share and running total. env.storage() .persistent() @@ -1414,6 +1457,43 @@ impl RevoraRevenueShare { env.storage().persistent().get(&DataKey2::SupplyCap(offering_id)).unwrap_or(0) } + pub fn set_max_total_supply_shares( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + max_total_supply_shares: i128, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + if let Err((err, _)) = + AmountValidationMatrix::validate(max_total_supply_shares, AmountValidationCategory::MaxTotalSupplyShares) + { + return Err(err); + } + + let offering_id = OfferingId { issuer, namespace, token }; + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id); + if max_total_supply_shares > 0 { + env.storage().persistent().set(&max_shares_key, &max_total_supply_shares); + } else { + env.storage().persistent().remove(&max_shares_key); + } + Ok(()) + } + + pub fn get_max_total_supply_shares(env: Env, issuer: Address, namespace: Symbol, token: Address) -> i128 { + let offering_id = OfferingId { issuer, namespace, token }; + env.storage().persistent().get(&DataKey2::MaxTotalSupplyShares(offering_id)).unwrap_or(0) + } + + pub fn get_total_shares_issued(env: Env, issuer: Address, namespace: Symbol, token: Address) -> i128 { + let offering_id = OfferingId { issuer, namespace, token }; + env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id)).unwrap_or(0) + } + // ── Fee BPS Configuration (#98) ────────────────────────────────────────── /// Set the global platform fee in basis points. Admin-only. (#98) @@ -2349,6 +2429,7 @@ impl RevoraRevenueShare { revenue_share_bps: u32, payout_asset: Address, supply_cap: i128, + max_total_supply_shares: i128, ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; Self::require_not_paused(&env)?; @@ -2361,6 +2442,13 @@ impl RevoraRevenueShare { return Err(err); } + // Negative Amount Validation Matrix: MaxTotalSupplyShares requires >= 0 + if let Err((err, _)) = + AmountValidationMatrix::validate(max_total_supply_shares, AmountValidationCategory::MaxTotalSupplyShares) + { + return Err(err); + } + // Skip bps validation in testnet mode (reads the real flag from storage). // In production mode (default) revenue_share_bps is always capped at 10 000 (100%). // Testnet mode is admin-only and must never be enabled on mainnet — see TESTNET_MODE.md. @@ -2420,6 +2508,11 @@ impl RevoraRevenueShare { env.storage().persistent().set(&cap_key, &supply_cap); } + if max_total_supply_shares > 0 { + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); + env.storage().persistent().set(&max_shares_key, &max_total_supply_shares); + } + Self::emit_v2_event( &env, (EVENT_OFFER_REG_V2, issuer.clone(), namespace.clone()), @@ -4952,6 +5045,35 @@ impl RevoraRevenueShare { .get(&DataKey::SnapshotHolderCount(offering_id.clone(), snapshot_ref)) .unwrap_or(0); + // Check max total supply shares cap first + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); + let max_shares: i128 = env.storage().persistent().get(&max_shares_key).unwrap_or(0); + let mut temp_total_shares: i128 = if max_shares > 0 { + env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id.clone())).unwrap_or(0) + } else { + 0 + }; + let mut temp_deltas: Vec<(Address, i128)> = Vec::new(&env); + + // First pass: calculate deltas and check cap + if max_shares > 0 { + for i in 0..batch_len { + let (holder, share_bps) = holders.get(i).unwrap(); + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + let delta = (share_bps as i128) - (old_share as i128); + temp_total_shares = temp_total_shares.saturating_add(delta); + temp_deltas.push_back((holder.clone(), delta)); + } + if temp_total_shares > max_shares { + return Err(RevoraError::MaxTotalSupplySharesExceeded); + } + } + + // Now apply the changes for i in 0..batch_len { let (holder, share_bps) = holders.get(i).unwrap(); let slot = start_index.saturating_add(i); @@ -4987,6 +5109,24 @@ impl RevoraRevenueShare { added_bps = added_bps.saturating_add(share_bps); } + // Update total shares issued + if max_shares > 0 { + env.storage().persistent().set(&DataKey2::TotalSharesIssued(offering_id.clone()), &temp_total_shares); + } else { + // If no cap, still track total shares + let mut total_shares: i128 = env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id.clone())).unwrap_or(0); + for i in 0..batch_len { + let (holder, share_bps) = holders.get(i).unwrap(); + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + total_shares = total_shares.saturating_sub(old_share as i128).saturating_add(share_bps as i128); + } + env.storage().persistent().set(&DataKey2::TotalSharesIssued(offering_id.clone()), &total_shares); + } + // Update snapshot metadata. if slot_count > entry.holder_count { entry.holder_count = slot_count; diff --git a/src/test_close_period.rs b/src/test_close_period.rs index b376be7e..f4b90c87 100644 --- a/src/test_close_period.rs +++ b/src/test_close_period.rs @@ -223,3 +223,101 @@ fn close_period_wrong_issuer_returns_not_found() { let result = client.try_close_period(&attacker, &ns, &token, &1); assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); } + +// ── Gas-bound tests: linear-in-holders cost ────────────────────────────────── + +/// Helper to compute CPU instruction delta of `close_period` call. +fn measure_cpu_for_n_holders(n: u32) -> u64 { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let issuer = Address::generate(&env); + let offering_token = Address::generate(&env); + let (payment_token, _) = create_payment_token(&env); + let ns = symbol_short!("ns"); + + client.register_offering(&issuer, &ns, &offering_token, &10_000, &payment_token, &0); + + for _ in 0..n { + let holder = Address::generate(&env); + client.set_holder_share(&issuer, &ns, &offering_token, &holder, &1); + } + + let before = env.budget().cpu_instruction_count(); + client.close_period(&issuer, &ns, &offering_token, &1); + let after = env.budget().cpu_instruction_count(); + after.saturating_sub(before) +} + +/// Compute R² (coefficient of determination) for a linear fit of (x,y) points. +fn r_squared(points: &[(f64, f64)]) -> f64 { + let n = points.len() as f64; + if n < 2 { + return 0.0; + } + + let mean_y = points.iter().map(|(_, y)| y).sum::() / n; + let ss_total: f64 = points.iter().map(|(_, y)| (y - mean_y).powi(2)).sum(); + if ss_total == 0.0 { + return 1.0; + } + + let sum_x = points.iter().map(|(x, _)| x).sum::(); + let sum_y = points.iter().map(|(_, y)| y).sum::(); + let sum_x_sq = points.iter().map(|(x, _)| x.powi(2)).sum::(); + let sum_xy = points.iter().map(|(x, y)| x * y).sum::(); + + let slope_numerator = n * sum_xy - sum_x * sum_y; + let slope_denominator = n * sum_x_sq - sum_x.powi(2); + if slope_denominator == 0.0 { + return 0.0; + } + + let slope = slope_numerator / slope_denominator; + let intercept = (sum_y - slope * sum_x) / n; + + let ss_residual: f64 = points.iter() + .map(|(x, y)| (y - (slope * x + intercept)).powi(2)) + .sum(); + + 1.0 - (ss_residual / ss_total) +} + +/// Test that close_period cost grows linearly with holder count (R² > 0.98). +#[test] +fn close_period_cpu_grows_linearly_with_holders() { + let test_counts = [1u32, 10u32, 100u32, 1000u32]; + let mut points = Vec::new(); + + for n in test_counts { + let cpu = measure_cpu_for_n_holders(n) as f64; + points.push((n as f64, cpu)); + } + + let r2 = r_squared(&points); + assert!(r2 > 0.98, "R² = {:.4} is below threshold of 0.98", r2); +} + +/// Test that zero-holder offering closes with constant cost. +#[test] +fn close_period_zero_holders_has_constant_cost() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let ns = symbol_short!("ns"); + let (payment_token, _) = create_payment_token(&env); + + client.register_offering(&issuer, &ns, &token, &10_000, &payment_token, &0); + + let before = env.budget().cpu_instruction_count(); + client.close_period(&issuer, &ns, &token, &1); + let after = env.budget().cpu_instruction_count(); + + assert!(after - before > 0, "CPU cost must be positive"); + // Assert that cost is within a reasonable constant bound + assert!(after - before < 5_000_000, "CPU cost {} exceeded constant bound", after - before); +}