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
28 changes: 23 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,12 @@ impl RevoraRevenueShare {
}
}

/// Enable or disable testnet mode for the contract.
///
/// ### Security Note
/// This mode MUST only be enabled on test networks. It relaxes critical
/// validation rules (like concentration limits) to facilitate automated
/// testing and integration flows.
pub fn set_testnet_mode(env: Env, enabled: bool) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
let admin: Address =
Expand Down Expand Up @@ -2220,12 +2226,17 @@ impl RevoraRevenueShare {
return Err(RevoraError::PayoutAssetMismatch);
}

// Testnet mode bypass: if enabled, skip concentration limit enforcement
// to allow flexible testing of revenue flows without holder constraints.
let testnet_mode = Self::is_testnet_mode(env.clone());
if !testnet_mode {
let limit_key = DataKey::ConcentrationLimit(offering_id.clone());
if let Some(config) =
env.storage().persistent().get::<DataKey, ConcentrationLimitConfig>(&limit_key)
{
// Concentration Enforcement: if enforce=true and max_bps > 0,
// reject report if current concentration exceeds the limit.
// Allowed: current <= max_bps. Rejected: current > max_bps.
if config.enforce && config.max_bps > 0 {
let curr_key = DataKey::CurrentConcentration(offering_id.clone());
let current: u32 = env.storage().persistent().get(&curr_key).unwrap_or(0);
Expand Down Expand Up @@ -3183,12 +3194,14 @@ impl RevoraRevenueShare {
/// - `Ok(())` on success.
/// - `Err(RevoraError::LimitReached)` if the offering is not found.
/// - `Err(RevoraError::ContractFrozen)` if the contract is frozen.
/// Configure the concentration limit for an offering.
///
/// ### Auth ordering
/// `issuer.require_auth()` is called immediately after the frozen/paused guards so that
/// unauthenticated callers cannot probe offering existence or trigger any side effects.
/// The identity check (`current_issuer != issuer`) follows auth, consistent with all other
/// issuer-gated setters in this contract.
/// ### Parameters
/// - `max_bps`: The maximum allowed share for a single holder in basis points.
/// - `enforce`: If true, `report_revenue` will fail if current concentration > `max_bps`.
///
/// ### Constraints
/// - `max_bps` must be <= 10,000.
pub fn set_concentration_limit(
env: Env,
issuer: Address,
Expand Down Expand Up @@ -3244,6 +3257,11 @@ impl RevoraRevenueShare {
/// Stores the provided concentration value. If it exceeds the configured limit,
/// a `conc_warn` event is emitted. The stored value is used for enforcement in `report_revenue`.
///
/// ### Enforcement Boundary
/// - If `enforce` is true in `ConcentrationLimitConfig`:
/// - `concentration_bps <= max_bps`: `report_revenue` is allowed.
/// - `concentration_bps > max_bps`: `report_revenue` is rejected.
///
/// ### Parameters
/// - `issuer`: The offering issuer. Must provide authentication.
/// - `token`: The token representing the offering.
Expand Down
41 changes: 40 additions & 1 deletion src/milestone_signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

use soroban_sdk::{symbol_short, testutils::Address as _, Address, Env, IntoVal, Symbol};

use crate::{RevoraRevenueShare, RevoraRevenueShareClient};
use crate::{RevoraError, RevoraRevenueShare, RevoraRevenueShareClient};

// ── helpers ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -312,6 +312,45 @@ fn milestone_concentration_warning_event_emitted() {
);
}

/// When concentration is exactly one bps over the limit, `report_revenue` is rejected.
#[test]
fn milestone_concentration_one_bps_over_limit_rejected() {
let env = Env::default();
let client = make_client(&env);
let (issuer, token, payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true);
client.report_concentration(&issuer, &ns, &token, &5_001u32);

let result = client.try_report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &1u64, &false);
assert_eq!(result, Err(Ok(RevoraError::ConcentrationLimitExceeded)));
}

/// When in testnet mode, concentration enforcement is bypassed even if over limit.
#[test]
fn milestone_concentration_testnet_mode_bypasses_enforcement() {
let env = Env::default();
let client = make_client(&env);
let (issuer, token, payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

// Enable testnet mode (requires admin auth)
client.set_testnet_mode(&true);

client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true);
client.report_concentration(&issuer, &ns, &token, &6_000u32);

// Should succeed despite being over limit
client.report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &1u64, &false);

assert_eq!(
client.get_audit_summary(&issuer, &ns, &token).unwrap().report_count,
1u64,
"testnet mode must bypass concentration enforcement"
);
}

// ── 5. Blacklist snapshot in rev_rep ─────────────────────────────────────────

/// The blacklist state at report time is observable via `get_blacklist`.
Expand Down
24 changes: 16 additions & 8 deletions src/proptest_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,6 @@ pub fn arb_set_concentration_limit() -> impl Strategy<Value = TestOperation> {

/// Strategy for any single valid operation (uniform distribution across all variants).
pub fn any_test_operation() -> impl Strategy<Value = TestOperation> {
/// Strategy for a single `ReportConcentration` operation.
pub fn arb_report_concentration() -> impl Strategy<Value = TestOperation> {
arb_valid_bps().prop_map(|concentration_bps| TestOperation::ReportConcentration { concentration_bps })
}

/// Strategy for any single valid operation (uniform distribution).
pub fn arb_any_operation() -> impl Strategy<Value = TestOperation> {
prop_oneof![
arb_register_offering(),
arb_report_revenue(),
Expand All @@ -209,10 +202,25 @@ pub fn arb_any_operation() -> impl Strategy<Value = TestOperation> {
arb_set_concentration_limit(),
arb_report_concentration(),
Just(TestOperation::Freeze),
arb_claim_delay_secs().prop_map(|d| TestOperation::SetClaimDelay { delay_secs: d }),
arb_claim_delay_secs().prop_map(|d| TestOperation::SetClaimDelay {
issuer: Address::generate(&Env::default()), // placeholder - proptest-helper addresses are updated in sequences
namespace: Symbol::new(&Env::default(), "def"),
token: Address::generate(&Env::default()),
delay_secs: d
}),
]
}

/// Strategy for a single `ReportConcentration` operation.
pub fn arb_report_concentration() -> impl Strategy<Value = TestOperation> {
arb_valid_bps().prop_map(|concentration_bps| TestOperation::ReportConcentration { concentration_bps })
}

/// Strategy for any single valid operation (uniform distribution).
pub fn arb_any_operation() -> impl Strategy<Value = TestOperation> {
any_test_operation()
}

/// Strategy for a sequence of `len` valid operations.
///
/// Period IDs in `ReportRevenue` and `DepositRevenue` operations are normalised
Expand Down
Loading