From d96e277cc75fc53b652c1b7d40b96b650e77dca1 Mon Sep 17 00:00:00 2001 From: Promise Nnamdi Ogazi <162865041+Escelit@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:45:44 +0000 Subject: [PATCH 1/2] feat: reject report_revenue on stale concentration when enforcement enabled --- README.md | 5 +- src/lib.rs | 38 ++++++- src/milestone_signals.rs | 12 +-- src/proptest_helpers.rs | 9 +- src/test.rs | 222 +++++++++++++++++++++++++++++++++++--- src/test_auth.rs | 4 + src/test_freeze_matrix.rs | 2 + 7 files changed, 262 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d831d635..93cee0ad 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Soroban contract for revenue-share offerings and blacklist management. | `blacklist_remove` | `caller: Address`, `token: Address`, `investor: Address` | — | issuer | Remove investor from blacklist. Only the current issuer can perform this action. Idempotent. | | `is_blacklisted` | `token: Address`, `investor: Address` | `bool` | — | Whether investor is blacklisted for token. | | `get_blacklist` | `token: Address` | `Vec
` | — | All blacklisted addresses for token. | -| `set_concentration_limit` | `issuer: Address`, `token: Address`, `max_bps: u32`, `enforce: bool` | `Result<(), RevoraError>` | issuer | Set per-offering max single-holder concentration (bps). 0 = disabled. If `enforce` is true, `report_revenue` fails when reported concentration > `max_bps`. Offering must exist. | +| `set_concentration_limit` | `issuer: Address`, `token: Address`, `max_bps: u32`, `enforce: bool`, `max_staleness_secs: u64` | `Result<(), RevoraError>` | issuer | Set per-offering max single-holder concentration (bps). 0 = disabled. If `enforce` is true, `report_revenue` fails when reported concentration > `max_bps`. When `max_staleness_secs > 0` and `enforce` is true, `report_revenue` also fails if no concentration has been reported or the last report is older than `max_staleness_secs` seconds. Offering must exist. | | `report_concentration` | `issuer: Address`, `token: Address`, `concentration_bps: u32` | `Result<(), RevoraError>` | issuer | Report current top-holder concentration (bps). Emits `conc_warn` if over configured limit. | | `get_concentration_limit` | `issuer: Address`, `token: Address` | `Option` | — | Get concentration limit config for offering. | | `get_current_concentration` | `issuer: Address`, `token: Address` | `Option` | — | Last reported concentration (bps) for offering. | @@ -56,7 +56,7 @@ Soroban contract for revenue-share offerings and blacklist management. ### Types - **Offering:** `{ issuer: Address, token: Address, revenue_share_bps: u32 }` -- **ConcentrationLimitConfig:** `{ max_bps: u32, enforce: bool }` — per-offering concentration guardrail. +- **ConcentrationLimitConfig:** `{ max_bps: u32, enforce: bool, max_staleness_secs: u64 }` — per-offering concentration guardrail. When `enforce` is true and `max_staleness_secs > 0`, `report_revenue` rejects with `StaleConcentrationData` if no concentration has been reported or the last report is older than `max_staleness_secs` seconds. - **AuditSummary:** `{ total_revenue: i128, report_count: u64 }` — per-offering audit log summary. - **RoundingMode:** `Truncation` (0) or `RoundHalfUp` (1) — used by `compute_share` and per-offering default. @@ -75,6 +75,7 @@ Soroban contract for revenue-share offerings and blacklist management. | 18 | `InvalidPeriodId` | period_id is 0 where a positive value is required (#35). | | 25 | `ReportingWindowClosed` | Current ledger timestamp is outside the configured reporting window; `report_revenue` rejected. | | 26 | `ClaimWindowClosed` | Current ledger timestamp is outside the configured claiming window; `claim` rejected. | +| 49 | `StaleConcentrationData` | `report_concentration` has never been called, or the last call is older than `max_staleness_secs`; `report_revenue` rejected when enforcement is on. | Auth failures (e.g. wrong signer) are signaled by host/panic, not `RevoraError`. Use `try_register_offering`, `try_report_revenue`, and similar `try_*` client methods to receive contract errors as `Result`. diff --git a/src/lib.rs b/src/lib.rs index a4eceed9..2302d48d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,6 +159,9 @@ pub enum RevoraError { /// /// Wire value: next available stable discriminant. MissingReportForOverride = 47, + /// Concentration data is missing or older than `max_staleness_secs` and enforcement is on. + /// Wire value: 49. Stable since v1. + StaleConcentrationData = 49, } pub mod vesting; @@ -393,6 +396,11 @@ pub struct ConcentrationLimitConfig { pub max_bps: u32, /// If true, `report_revenue` will fail if current concentration exceeds `max_bps`. pub enforce: bool, + /// Maximum age (in seconds) of a `report_concentration` call before it is considered stale. + /// When `enforce` is true and this is > 0, `report_revenue` rejects if no concentration has + /// been reported or the last report is older than this many seconds. 0 = disabled (no staleness + /// check). + pub max_staleness_secs: u64, } /// Per-offering investment constraints (#97). Min/max stake per investor; off-chain enforced. @@ -594,6 +602,8 @@ pub enum DataKey { ConcentrationLimit(OfferingId), /// Per-offering: last reported concentration in bps. CurrentConcentration(OfferingId), + /// Per-offering: ledger timestamp of the last report_concentration call. + ConcentrationReportedAt(OfferingId), /// Per-offering: audit summary. AuditSummary(OfferingId), /// Per-offering: rounding mode for share math. @@ -2448,6 +2458,22 @@ impl RevoraRevenueShare { // reject report if current concentration exceeds the limit. // Allowed: current <= max_bps. Rejected: current > max_bps. if config.enforce && config.max_bps > 0 { + // Staleness guard: if max_staleness_secs > 0, require a fresh report. + if config.max_staleness_secs > 0 { + let reported_at: Option = env.storage().persistent().get( + &DataKey::ConcentrationReportedAt(offering_id.clone()), + ); + match reported_at { + None => return Err(RevoraError::StaleConcentrationData), + Some(ts) => { + if current_timestamp.saturating_sub(ts) + > config.max_staleness_secs + { + return Err(RevoraError::StaleConcentrationData); + } + } + } + } let curr_key = DataKey::CurrentConcentration(offering_id.clone()); let current: u32 = env.storage().persistent().get(&curr_key).unwrap_or(0); if current > config.max_bps { @@ -3787,6 +3813,9 @@ impl RevoraRevenueShare { /// ### 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`. + /// - `max_staleness_secs`: When > 0 and `enforce` is true, `report_revenue` rejects if no + /// concentration has been reported or the last report is older than this many seconds. + /// Set to 0 to disable the staleness check. /// /// ### Constraints /// - `max_bps` must be <= 10,000. @@ -3797,6 +3826,7 @@ impl RevoraRevenueShare { token: Address, max_bps: u32, enforce: bool, + max_staleness_secs: u64, ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; Self::require_not_paused(&env)?; @@ -3826,7 +3856,9 @@ impl RevoraRevenueShare { if !Self::is_event_only(&env) { let key = DataKey::ConcentrationLimit(offering_id); - env.storage().persistent().set(&key, &ConcentrationLimitConfig { max_bps, enforce }); + env.storage() + .persistent() + .set(&key, &ConcentrationLimitConfig { max_bps, enforce, max_staleness_secs }); } Self::emit_v2_event( @@ -3907,6 +3939,10 @@ impl RevoraRevenueShare { env.storage() .persistent() .set(&DataKey::CurrentConcentration(offering_id.clone()), &concentration_bps); + env.storage().persistent().set( + &DataKey::ConcentrationReportedAt(offering_id.clone()), + &env.ledger().timestamp(), + ); env.events().publish( (EVENT_CONCENTRATION_REPORTED, issuer, namespace, token), concentration_bps, diff --git a/src/milestone_signals.rs b/src/milestone_signals.rs index d2fbc103..01f37744 100644 --- a/src/milestone_signals.rs +++ b/src/milestone_signals.rs @@ -244,7 +244,7 @@ fn milestone_concentration_enforcement_blocks_revenue_report() { let (issuer, token, payout) = setup_offering(&env, &client); let ns = symbol_short!("def"); - client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true); + client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64); client.report_concentration(&issuer, &ns, &token, &6_000u32); assert!( @@ -267,7 +267,7 @@ fn milestone_concentration_at_limit_allows_revenue_report() { let (issuer, token, payout) = setup_offering(&env, &client); let ns = symbol_short!("def"); - client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true); + client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64); client.report_concentration(&issuer, &ns, &token, &5_000u32); client.report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &1u64, &false); @@ -283,7 +283,7 @@ fn milestone_concentration_warning_does_not_block_report() { let (issuer, token, payout) = setup_offering(&env, &client); let ns = symbol_short!("def"); - client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false); + client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false, &0u64); client.report_concentration(&issuer, &ns, &token, &8_000u32); client.report_revenue(&issuer, &ns, &token, &payout, &500i128, &1u64, &false); @@ -303,7 +303,7 @@ fn milestone_concentration_warning_event_emitted() { let (issuer, token, _payout) = setup_offering(&env, &client); let ns = symbol_short!("def"); - client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false); + client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false, &0u64); client.report_concentration(&issuer, &ns, &token, &8_000u32); assert!( @@ -320,7 +320,7 @@ fn milestone_concentration_one_bps_over_limit_rejected() { let (issuer, token, payout) = setup_offering(&env, &client); let ns = symbol_short!("def"); - client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true); + client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64); client.report_concentration(&issuer, &ns, &token, &5_001u32); let result = client.try_report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &1u64, &false); @@ -338,7 +338,7 @@ fn milestone_concentration_testnet_mode_bypasses_enforcement() { // Enable testnet mode (requires admin auth) client.set_testnet_mode(&true); - client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true); + client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64); client.report_concentration(&issuer, &ns, &token, &6_000u32); // Should succeed despite being over limit diff --git a/src/proptest_helpers.rs b/src/proptest_helpers.rs index a6ffe9f7..8628ed0b 100644 --- a/src/proptest_helpers.rs +++ b/src/proptest_helpers.rs @@ -131,8 +131,8 @@ pub enum TestOperation { BlacklistAdd { caller: Address, issuer: Address, namespace: Symbol, token: Address, investor: Address }, /// `blacklist_remove(caller, issuer, namespace, token, investor)` BlacklistRemove { caller: Address, issuer: Address, namespace: Symbol, token: Address, investor: Address }, - /// `set_concentration_limit(issuer, namespace, token, max_bps, enforce)` - SetConcentrationLimit { max_bps: u32, enforce: bool }, + /// `set_concentration_limit(issuer, namespace, token, max_bps, enforce, max_staleness_secs)` + SetConcentrationLimit { max_bps: u32, enforce: bool, max_staleness_secs: u64 }, /// `report_concentration(issuer, namespace, token, concentration_bps)` ReportConcentration { concentration_bps: u32 }, /// `freeze()` — admin-only global freeze @@ -186,8 +186,9 @@ pub fn arb_blacklist_remove() -> impl Strategy { /// Strategy for a single `SetConcentrationLimit` operation. pub fn arb_set_concentration_limit() -> impl Strategy { - (arb_valid_bps(), any::()) - .prop_map(|(max_bps, enforce)| TestOperation::SetConcentrationLimit { max_bps, enforce }) + (arb_valid_bps(), any::()).prop_map(|(max_bps, enforce)| { + TestOperation::SetConcentrationLimit { max_bps, enforce, max_staleness_secs: 0 } + }) } /// Strategy for any single valid operation (uniform distribution across all variants). diff --git a/src/test.rs b/src/test.rs index e2b9fa61..c6d4d192 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1258,7 +1258,7 @@ fn set_concentration_limit_requires_offering_to_exist() { let token = Address::generate(&env); // No offering registered let r = - client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false, &0u64); assert!(r.is_err()); } @@ -1271,7 +1271,7 @@ fn set_concentration_limit_stores_config() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false, &0u64); let config = client.get_concentration_limit(&issuer, &symbol_short!("def"), &token); assert_eq!(config.clone().unwrap().max_bps, 5000); assert!(!config.clone().unwrap().enforce); @@ -1291,7 +1291,7 @@ fn set_concentration_limit_bounds_check() { client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); let res = - client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &10001, &false); + client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &10001, &false, &0u64); assert!(res.is_err()); } @@ -1324,7 +1324,7 @@ fn set_concentration_limit_respects_pause() { client.pause_admin(&admin); let res = - client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false, &0u64); assert!(res.is_err()); } @@ -1373,7 +1373,7 @@ fn report_concentration_emits_warning_when_over_limit() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false, &0u64); let before = env.events().all().len(); client.report_concentration(&issuer, &symbol_short!("def"), &token, &6000); assert!(env.events().all().len() > before); @@ -1392,7 +1392,7 @@ fn report_concentration_no_warning_when_below_limit() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false, &0u64); client.report_concentration(&issuer, &symbol_short!("def"), &token, &4000); assert_eq!( client.get_current_concentration(&issuer, &symbol_short!("def"), &token), @@ -1409,7 +1409,7 @@ fn concentration_enforce_blocks_report_revenue_when_over_limit() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &0u64); client.report_concentration(&issuer, &symbol_short!("def"), &token, &6000); let r = client.try_report_revenue( &issuer, @@ -1435,7 +1435,7 @@ fn concentration_enforce_allows_report_revenue_when_at_or_below_limit() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &0u64); client.report_concentration(&issuer, &symbol_short!("def"), &token, &5000); client.report_revenue( &issuer, @@ -1467,7 +1467,7 @@ fn concentration_near_threshold_boundary() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &0u64); client.report_concentration(&issuer, &symbol_short!("def"), &token, &5001); assert!(client @@ -1520,6 +1520,7 @@ fn set_concentration_limit_requires_auth_before_state_read() { &token, &5_000, &false, + &0u64, ); assert!(result.is_err(), "unauthenticated call must be rejected"); } @@ -1549,6 +1550,7 @@ fn set_concentration_limit_auth_required_even_in_event_only_mode() { &token, &5_000, &false, + &0u64, ); // In event-only mode the function returns Ok but does not write storage. assert!(result.is_ok(), "authenticated call in event-only mode must return Ok"); @@ -1577,10 +1579,195 @@ fn set_concentration_limit_wrong_issuer_rejected_after_auth() { &token, &5_000, &false, + &0u64, ); assert!(result.is_err(), "non-issuer must be rejected"); } +// --------------------------------------------------------------------------- +// Concentration staleness guard (#355) +// --------------------------------------------------------------------------- + +/// report_revenue must fail with StaleConcentrationData when enforce=true, +/// max_staleness_secs > 0, and no concentration has ever been reported. +#[test] +fn concentration_staleness_no_prior_report_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); + // enforce=true, max_staleness_secs=3600 — no report_concentration called yet + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &3600u64); + let r = client.try_report_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &1_000, + &1, + &false, + ); + assert_eq!( + r, + Err(Ok(RevoraError::StaleConcentrationData)), + "must reject when no concentration has been reported and staleness guard is on" + ); +} + +/// report_revenue must fail with StaleConcentrationData when the last +/// report_concentration is older than max_staleness_secs. +#[test] +fn concentration_staleness_stale_report_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &3600u64); + + // Report concentration at t=1000 + env.ledger().set_timestamp(1000); + client.report_concentration(&issuer, &symbol_short!("def"), &token, &4000); + + // Advance time past the staleness window (1000 + 3600 + 1 = 4601) + env.ledger().set_timestamp(4601); + let r = client.try_report_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &1_000, + &1, + &false, + ); + assert_eq!( + r, + Err(Ok(RevoraError::StaleConcentrationData)), + "must reject when concentration report is older than max_staleness_secs" + ); +} + +/// report_revenue must succeed when concentration was reported within the +/// staleness window. +#[test] +fn concentration_staleness_fresh_report_allowed() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &3600u64); + + // Report concentration at t=1000 + env.ledger().set_timestamp(1000); + client.report_concentration(&issuer, &symbol_short!("def"), &token, &4000); + + // Advance time but stay within the window (1000 + 3600 = 4600, so 4600 is still valid) + env.ledger().set_timestamp(4600); + client.report_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &1_000, + &1, + &false, + ); +} + +/// When enforce=false, the staleness guard must not apply even if +/// max_staleness_secs > 0 and no concentration has been reported. +#[test] +fn concentration_staleness_enforce_off_bypasses_guard() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); + // enforce=false — staleness guard must not fire + client.set_concentration_limit( + &issuer, + &symbol_short!("def"), + &token, + &5000, + &false, + &3600u64, + ); + // No report_concentration called + client.report_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &1_000, + &1, + &false, + ); +} + +/// When max_staleness_secs=0, the staleness guard is disabled even if +/// enforce=true and no concentration has been reported. +#[test] +fn concentration_staleness_zero_secs_disables_guard() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); + // max_staleness_secs=0 — guard disabled + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &0u64); + // No report_concentration called — should not be rejected for staleness + client.report_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &1_000, + &1, + &false, + ); +} + +/// Boundary: report exactly at the edge of the staleness window (now - ts == max_staleness_secs) +/// must be allowed (inclusive boundary). +#[test] +fn concentration_staleness_boundary_exact_window_allowed() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &3600u64); + + env.ledger().set_timestamp(1000); + client.report_concentration(&issuer, &symbol_short!("def"), &token, &4000); + + // Exactly at the boundary: now - ts = 3600 == max_staleness_secs → allowed + env.ledger().set_timestamp(4600); + client.report_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &1_000, + &1, + &false, + ); +} + // --------------------------------------------------------------------------- // Auth-first ordering: set_rounding_mode (#auth-order) // --------------------------------------------------------------------------- @@ -5771,7 +5958,7 @@ fn testnet_mode_skips_concentration_enforcement() { // Register offering and set concentration limit with enforcement client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &0u64); client.report_concentration(&issuer, &symbol_short!("def"), &token, &8000); // Over limit // In testnet mode, report_revenue should succeed despite concentration being over limit @@ -5872,7 +6059,7 @@ fn testnet_mode_disabled_enforces_concentration() { // Testnet mode disabled (default) client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &true, &0u64); client.report_concentration(&issuer, &symbol_short!("def"), &token, &8000); // Over limit // Should fail with concentration enforcement @@ -5986,7 +6173,7 @@ fn testnet_mode_concentration_warning_still_emitted() { client.set_testnet_mode(&true); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false, &0u64); // Warning should still be emitted in testnet mode let before = legacy_events(&env).len(); @@ -7208,6 +7395,7 @@ fn issuer_transfer_new_issuer_can_set_concentration_limit() { &token, &5_000, &true, + &0u64, ); assert!(result.is_ok()); } @@ -7341,7 +7529,7 @@ fn issuer_transfer_new_issuer_can_report_concentration() { let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); let new_issuer = Address::generate(&env); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &6_000, &false); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &6_000, &false, &0u64); client.propose_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer); client.accept_issuer_transfer(&issuer, &symbol_short!("def"), &token); @@ -8104,8 +8292,8 @@ proptest! { TestOperation::ReportRevenue { amount, period_id, override_existing } => { let _ = client.try_report_revenue(&issuer, &ns, &token, &token, &amount, &period_id, &override_existing); } - TestOperation::SetConcentrationLimit { max_bps, enforce: e } => { - let _ = client.try_set_concentration_limit(&issuer, &ns, &token, &max_bps, &e); + TestOperation::SetConcentrationLimit { max_bps, enforce: e, max_staleness_secs } => { + let _ = client.try_set_concentration_limit(&issuer, &ns, &token, &max_bps, &e, &max_staleness_secs); } TestOperation::ReportConcentration { concentration_bps } => { let _ = client.try_report_concentration(&issuer, &ns, &token, &concentration_bps); @@ -8115,7 +8303,7 @@ proptest! { } // Set target configuration - client.set_concentration_limit(&issuer, &ns, &token.clone(), &limit_bps, &enforce); + client.set_concentration_limit(&issuer, &ns, &token.clone(), &limit_bps, &enforce, &0u64); // Report concentration over limit client.report_concentration(&issuer, &ns, &token.clone(), &conc_bps); @@ -10317,7 +10505,7 @@ mod regression { fn set_concentration_limit_emits_event() { let (env, client, issuer, token, _) = setup_with_offering(); let before = env.events().all().len(); - client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5_000, &true); + client.set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5_000, &true, &0u64); assert!(env.events().all().len() > before); } diff --git a/src/test_auth.rs b/src/test_auth.rs index 41b902ed..565fa5e7 100644 --- a/src/test_auth.rs +++ b/src/test_auth.rs @@ -394,6 +394,7 @@ fn set_concentration_limit_wrong_issuer_no_mutation() { &token, &1_000u32, &true, + &0u64, ); assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); assert!( @@ -706,6 +707,7 @@ fn cross_offering_concentration_limit_wrong_issuer() { &token_b, &5_000u32, &false, + &0u64, ); assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); assert!( @@ -889,6 +891,7 @@ fn set_concentration_limit_blocked_when_frozen() { &token, &2_000u32, &true, + &0u64, ); assert_eq!(result, Err(Ok(RevoraError::ContractFrozen))); assert!( @@ -1025,6 +1028,7 @@ fn issuer_can_configure_offering_settings() { &token, &3_000u32, &false, + &0u64, ); let cfg = client .get_concentration_limit(&issuer, &symbol_short!("def"), &token) diff --git a/src/test_freeze_matrix.rs b/src/test_freeze_matrix.rs index 64f021fb..110b7fd1 100644 --- a/src/test_freeze_matrix.rs +++ b/src/test_freeze_matrix.rs @@ -234,6 +234,7 @@ fn frozen_set_concentration_limit_returns_contract_frozen() { &token, &5_000u32, &true, + &0u64, ); assert_frozen_err(result); assert!(client.get_concentration_limit(&issuer, &symbol_short!("ns"), &token).is_none()); @@ -566,6 +567,7 @@ fn frozen_set_concentration_limit_no_partial_write() { &token, &3_000u32, &true, + &0u64, ); assert!(client.get_concentration_limit(&issuer, &symbol_short!("ns"), &token).is_none()); From 29247a7445b9259d66dcff813b071906c7d6e88a Mon Sep 17 00:00:00 2001 From: Promise Nnamdi Ogazi <162865041+Escelit@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:12:02 +0000 Subject: [PATCH 2/2] fix: resolve fmt and clippy CI failures - cargo fmt: fix BOM, long lines, method chain formatting across lib.rs, test_multisig_gas.rs, vesting.rs - lib.rs: add clippy::enum_variant_names to global allow list (PauseState variants all share 'Paused' postfix by design) - lib.rs: remove spurious deref (*share_bps) on u32 values in apply_snapshot_shares (lines 4738, 4749) - vesting.rs: clone issuer/token before moving into VestingSchedule struct to fix borrow-after-move errors - vesting.rs: add Vec
type annotation for beneficiaries and migrated to resolve type inference failures - vesting.rs: collapse nested if into single condition to satisfy clippy::collapsible_if - test_pause_tiers.rs: add '_ lifetime to RevoraRevenueShareClient return types to fix mismatched_lifetime_syntaxes --- src/lib.rs | 34 +++++++++++++++++++++----------- src/test_multisig_gas.rs | 2 +- src/test_pause_tiers.rs | 6 +++--- src/vesting.rs | 42 ++++++++++++++++++++++++++-------------- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2302d48d..a89d9ef9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] #![deny(unsafe_code)] #![allow(dead_code)] #![allow(unused_variables)] @@ -37,7 +37,8 @@ clippy::manual_let_else, clippy::empty_line_after_doc_comments, clippy::doc_lazy_continuation, - clippy::unnecessary_lazy_evaluations + clippy::unnecessary_lazy_evaluations, + clippy::enum_variant_names )] use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address, @@ -1203,7 +1204,11 @@ impl RevoraRevenueShare { (holder.clone(), share_bps), ); // Versioned v2 event: [2, holder, share_bps] — always emitted (#RC26Q2-C31) - Self::emit_v2_event(env, (EVENT_SHARE_SET_V2, issuer, namespace, token), (holder, share_bps)); + Self::emit_v2_event( + env, + (EVENT_SHARE_SET_V2, issuer, namespace, token), + (holder, share_bps), + ); Ok(()) } @@ -2460,9 +2465,10 @@ impl RevoraRevenueShare { if config.enforce && config.max_bps > 0 { // Staleness guard: if max_staleness_secs > 0, require a fresh report. if config.max_staleness_secs > 0 { - let reported_at: Option = env.storage().persistent().get( - &DataKey::ConcentrationReportedAt(offering_id.clone()), - ); + let reported_at: Option = env + .storage() + .persistent() + .get(&DataKey::ConcentrationReportedAt(offering_id.clone())); match reported_at { None => return Err(RevoraError::StaleConcentrationData), Some(ts) => { @@ -4730,7 +4736,7 @@ impl RevoraRevenueShare { .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) .unwrap_or(0); - let new_total = current_total.saturating_sub(old_share).saturating_add(*share_bps); + let new_total = current_total.saturating_sub(old_share).saturating_add(share_bps); if new_total > 10_000 { return Err(RevoraError::InvalidShareBps); } @@ -4741,7 +4747,7 @@ impl RevoraRevenueShare { .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); current_total = new_total; - added_bps = added_bps.saturating_add(*share_bps); + added_bps = added_bps.saturating_add(share_bps); } // Update snapshot metadata. @@ -4752,7 +4758,9 @@ impl RevoraRevenueShare { env.storage().persistent().set(&entry_key, &entry); // Persist updated per-offering running total. - env.storage().persistent().set(&DataKey::HolderShareTotal(offering_id.clone()), ¤t_total); + env.storage() + .persistent() + .set(&DataKey::HolderShareTotal(offering_id.clone()), ¤t_total); env.events().publish( (EVENT_SNAP_SHARES_APPLIED, issuer, namespace, token), @@ -4809,7 +4817,11 @@ impl RevoraRevenueShare { Self::require_not_frozen(&env)?; Self::require_not_paused(&env)?; issuer.require_auth(); - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; Self::get_current_issuer( &env, issuer.clone(), @@ -6534,4 +6546,4 @@ mod issue_370_373_tests { i128::MIN ); } -} \ No newline at end of file +} diff --git a/src/test_multisig_gas.rs b/src/test_multisig_gas.rs index 2d3ca249..e72a2367 100644 --- a/src/test_multisig_gas.rs +++ b/src/test_multisig_gas.rs @@ -137,7 +137,7 @@ fn execute_remove_owner_at_max_owners_within_budget() { let (env, id, _client, _admin, owners) = setup_max_multisig(); let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; // 11 - // Remove the last owner (index 19) — it is not the proposer. + // Remove the last owner (index 19) — it is not the proposer. let target = owners.get(RevoraRevenueShare::MAX_MULTISIG_OWNERS - 1).unwrap(); let action = ProposalAction::RemoveOwner(target); diff --git a/src/test_pause_tiers.rs b/src/test_pause_tiers.rs index 53835702..8efa814b 100644 --- a/src/test_pause_tiers.rs +++ b/src/test_pause_tiers.rs @@ -30,13 +30,13 @@ use crate::{PauseState, RevoraError, RevoraRevenueShare, RevoraRevenueShareClien // ── helpers ────────────────────────────────────────────────────────────────── -fn make_client(env: &Env) -> RevoraRevenueShareClient { +fn make_client(env: &Env) -> RevoraRevenueShareClient<'_> { let id = env.register_contract(None, RevoraRevenueShare); RevoraRevenueShareClient::new(env, &id) } /// Initialize with both admin and safety roles; mock all auths for the test. -fn setup(env: &Env) -> (RevoraRevenueShareClient, Address, Address) { +fn setup(env: &Env) -> (RevoraRevenueShareClient<'_>, Address, Address) { env.mock_all_auths(); let client = make_client(env); let admin = Address::generate(env); @@ -51,7 +51,7 @@ fn setup(env: &Env) -> (RevoraRevenueShareClient, Address, Address) { /// Returns `(client, admin, safety, issuer, offering_token, payment_token, holder)`. fn setup_with_offering( env: &Env, -) -> (RevoraRevenueShareClient, Address, Address, Address, Address, Address, Address) { +) -> (RevoraRevenueShareClient<'_>, Address, Address, Address, Address, Address, Address) { env.mock_all_auths(); let client = make_client(env); let admin = Address::generate(env); diff --git a/src/vesting.rs b/src/vesting.rs index 68c18cab..860d9cf3 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -107,9 +107,9 @@ impl VestingContract { } let schedule = VestingSchedule { - issuer, + issuer: issuer.clone(), beneficiary: beneficiary.clone(), - token, + token: token.clone(), total_amount, cliff_ts, start_ts, @@ -218,40 +218,54 @@ pub fn migrate_offering_schedules( return Ok(Vec::new(env)); } - let mut beneficiaries = Vec::new(env); + let mut beneficiaries: Vec
= Vec::new(env); for i in 0..count { - if let Some(beneficiary) = - env.storage().persistent().get(&VestingKey::OfferingScheduleItem(offering_id.clone(), i)) + if let Some(beneficiary) = env + .storage() + .persistent() + .get(&VestingKey::OfferingScheduleItem(offering_id.clone(), i)) { beneficiaries.push_back(beneficiary); } } - let new_offering_id = VestingOfferingId { issuer: new_issuer.clone(), token: offering_id.token.clone() }; + let new_offering_id = + VestingOfferingId { issuer: new_issuer.clone(), token: offering_id.token.clone() }; let mut new_count: u32 = env .storage() .persistent() .get(&VestingKey::OfferingScheduleCount(new_offering_id.clone())) .unwrap_or(0); - let mut migrated = Vec::new(&env); + let mut migrated: Vec
= Vec::new(env); // First pass: validate that no schedule is pre-cliff. for beneficiary in beneficiaries.iter() { - if let Some(schedule) = env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone())) { - if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token { - if now < schedule.cliff_ts { - return Err(VestingError::SchedulePreCliff); - } + if let Some(schedule) = env + .storage() + .persistent() + .get::(&VestingKey::Schedule(beneficiary.clone())) + { + if schedule.issuer == offering_id.issuer + && schedule.token == offering_id.token + && now < schedule.cliff_ts + { + return Err(VestingError::SchedulePreCliff); } } } // Second pass: migrate matching schedules and rebuild the beneficiary index. for beneficiary in beneficiaries.iter() { - if let Some(mut schedule) = env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone())) { + if let Some(mut schedule) = env + .storage() + .persistent() + .get::(&VestingKey::Schedule(beneficiary.clone())) + { if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token { schedule.issuer = new_issuer.clone(); - env.storage().persistent().set(&VestingKey::Schedule(beneficiary.clone()), &schedule); + env.storage() + .persistent() + .set(&VestingKey::Schedule(beneficiary.clone()), &schedule); env.storage().persistent().set( &VestingKey::OfferingScheduleItem(new_offering_id.clone(), new_count), &beneficiary.clone(),