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