Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CLOSE_PERIOD_GAS_TEST_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -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
148 changes: 135 additions & 13 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,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
Expand Down Expand Up @@ -998,8 +1000,15 @@ pub enum DataKey2 {

/// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period.
ClosedPeriod(OfferingId, u64),
/// Per-offering supply cap configuration.

/// 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.
Expand Down Expand Up @@ -1064,6 +1073,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.
Expand Down Expand Up @@ -1170,6 +1182,11 @@ impl AmountValidationMatrix {
}
}
AmountValidationCategory::Simulation => {}
AmountValidationCategory::MaxTotalSupplyShares => {
if amount < 0 {
return Err((RevoraError::InvalidAmount, symbol_short!("no_neg")));
}
}
}
Ok(())
}
Expand Down Expand Up @@ -1244,6 +1261,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,
}
}
Expand Down Expand Up @@ -1551,12 +1569,22 @@ impl RevoraRevenueShare {
token: token.clone(),
};

Self::require_holder_jurisdiction_allowed(
env,
&offering_id,
&holder,
EVENT_JUR_ACTION_SHARE,
)?;
// 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());
Expand Down Expand Up @@ -1584,7 +1612,11 @@ impl RevoraRevenueShare {
return Err(RevoraError::InvalidShareBps);
}

Self::cache_holder_accrual_through_matured(env, &offering_id, &holder);
// 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()
Expand Down Expand Up @@ -2099,6 +2131,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)
Expand Down Expand Up @@ -3180,7 +3249,7 @@ impl RevoraRevenueShare {
revenue_share_bps: u32,
payout_asset: Address,
supply_cap: i128,
classes: Option<Vec<(ShareClass, ClassConfig)>>,
max_total_supply_shares: i128,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
Self::require_not_paused(&env)?;
Expand Down Expand Up @@ -3210,6 +3279,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.
Expand Down Expand Up @@ -3273,10 +3349,9 @@ impl RevoraRevenueShare {
env.storage().persistent().set(&cap_key, &supply_cap);
}

if let Some(ref cls_vec) = classes {
env.storage()
.persistent()
.set(&DataKey2::OfferingClasses(offering_id.clone()), cls_vec);
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(
Expand Down Expand Up @@ -5991,6 +6066,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);
Expand Down Expand Up @@ -6029,6 +6133,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;
Expand Down
101 changes: 93 additions & 8 deletions src/test_close_period.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,100 @@ fn close_period_wrong_issuer_returns_not_found() {
assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound)));
}

// --- INJECTED DEFERRED TESTS ---
// ── 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::<f64>() / 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::<f64>();
let sum_y = points.iter().map(|(_, y)| y).sum::<f64>();
let sum_x_sq = points.iter().map(|(x, _)| x.powi(2)).sum::<f64>();
let sum_xy = points.iter().map(|(x, y)| x * y).sum::<f64>();

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]
#[should_panic(expected = "Error(Contract, #456)")]
fn test_claim_on_deferred_fails() {
let env = soroban_sdk::Env::default();
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 contract_id = env.register_contract(None, RevoraRevenueShare);
let client = RevoraRevenueShareClient::new(&env, &contract_id);
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();

client.report_revenue(&2, &5000, &true);
client.claim(&2);
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);
}
Loading