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(),