diff --git a/README.md b/README.md
index 80e15acc..bca5811c 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. |
@@ -57,7 +57,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.
@@ -76,7 +76,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. |
-| 47 | `MissingReportForOverride` | `report_revenue` rejected when `override_existing=true` is requested for a period that has no existing persisted report. Emits `rev_omiss`. |
+| 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 b47ce68b..cd3d6974 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -160,6 +160,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;
@@ -386,6 +389,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.
@@ -608,6 +616,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.
@@ -2575,6 +2585,23 @@ 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 {
@@ -3937,6 +3964,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.
@@ -3947,6 +3977,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)?;
@@ -3976,7 +4007,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(
@@ -4057,6 +4090,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 965f1f96..1f2b064d 100644
--- a/src/proptest_helpers.rs
+++ b/src/proptest_helpers.rs
@@ -143,8 +143,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
@@ -198,8 +198,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 55046687..a949c78f 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();
@@ -7447,6 +7634,7 @@ fn issuer_transfer_new_issuer_can_set_concentration_limit() {
&token,
&5_000,
&true,
+ &0u64,
);
assert!(result.is_ok());
}
@@ -7580,7 +7768,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);
@@ -8343,8 +8531,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);
@@ -8354,7 +8542,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);
@@ -10556,7 +10744,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());
diff --git a/src/vesting.rs b/src/vesting.rs
index a9bfdfc5..e247a02e 100644
--- a/src/vesting.rs
+++ b/src/vesting.rs
@@ -218,7 +218,7 @@ pub fn migrate_offering_schedules(
return Ok(Vec::new(env));
}
- let mut beneficiaries: soroban_sdk::Vec = Vec::new(env);
+ let mut beneficiaries: Vec = Vec::new(env);
for i in 0..count {
if let Some(beneficiary) = env
.storage()
@@ -236,22 +236,31 @@ pub fn migrate_offering_schedules(
.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()